From 16d449d841e18d0ebaf00cd71c183736d43c0591 Mon Sep 17 00:00:00 2001 From: Stavros Kois <47820033+stavros-k@users.noreply.github.com> Date: Tue, 14 Jan 2025 10:07:57 +0200 Subject: [PATCH] apps: bump library (#1353) --- ix-dev/community/actual-budget/app.yaml | 6 +- .../templates/library/base_v2_1_8/ports.py | 68 --- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../{base_v2_1_8 => base_v2_1_9}/container.py | 7 +- .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9}/tests/test_container.py | 14 +- .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/adguard-home/app.yaml | 6 +- .../templates/library/base_v2_1_8/ports.py | 68 --- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../{base_v2_1_8 => base_v2_1_9}/container.py | 7 +- .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9}/tests/test_container.py | 14 +- .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/audiobookshelf/app.yaml | 6 +- .../templates/library/base_v2_1_8/ports.py | 68 --- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../{base_v2_1_8 => base_v2_1_9}/container.py | 7 +- .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9}/tests/test_container.py | 14 +- .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/autobrr/app.yaml | 6 +- .../templates/library/base_v2_1_8/ports.py | 68 --- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../{base_v2_1_8 => base_v2_1_9}/container.py | 7 +- .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9}/tests/test_container.py | 14 +- .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/bazarr/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/briefkasten/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/calibre-web/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/calibre/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/castopod/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/chia/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/clamav/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/cloudflared/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/dashy/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/ddns-updater/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 .../{v1_1_8 => v1_1_9}/__init__.py | 0 .../ddns-updater/{v1_1_8 => v1_1_9}/config.py | 0 ix-dev/community/deluge/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/distribution/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/dockge/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/drawio/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/eclipse-mosquitto/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/filebrowser/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/filestash/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/firefly-iii/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/flame/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/flaresolverr/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/freshrss/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/frigate/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/fscrawler/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/gaseous-server/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/gitea/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/grafana/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/handbrake/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/homarr/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/homepage/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/homer/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 .../community/iconik-storage-gateway/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/immich/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/invidious/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/invoice-ninja/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/ipfs/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/jellyfin/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/jellyseerr/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/jelu/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/jenkins/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/joplin/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/kapowarr/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/kavita/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/komga/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/lidarr/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/linkding/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/listmonk/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/logseq/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/lyrion-music-server/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/mealie/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/metube/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/minecraft/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/mineos/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/mumble/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/n8n/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/navidrome/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/netbootxyz/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/nginx-proxy-manager/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/node-red/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/odoo/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/ollama/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/omada-controller/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/open-webui/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/organizr/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/overseerr/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/palworld/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/paperless-ngx/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/passbolt/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/penpot/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/pgadmin/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/pigallery2/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/piwigo/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/planka/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/plex-auto-languages/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/portainer/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/postgres/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/prowlarr/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/qbittorrent/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/radarr/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/readarr/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/recyclarr/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/redis/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/roundcube/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/rsyncd/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/rust-desk/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/sabnzbd/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/satisfactory-server/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/scrutiny/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/searxng/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/sftpgo/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/sonarr/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/tailscale/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/tautulli/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/tdarr/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/terraria/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/tftpd-hpa/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/tianji/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/tiny-media-manager/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/transmission/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/twofactor-auth/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/umami/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/unifi-controller/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 .../community/unifi-protect-backup/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/uptime-kuma/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/vaultwarden/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/vikunja/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/webdav/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/whoogle/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/wordpress/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/zerotier/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/community/zigbee2mqtt/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/enterprise/asigra-ds-system/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/enterprise/minio/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/enterprise/syncthing/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/stable/collabora/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/stable/diskoverdata/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/stable/elastic-search/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/stable/emby/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/stable/home-assistant/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/stable/ix-app/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/stable/minio/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/stable/netdata/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/stable/nextcloud/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/stable/photoprism/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/stable/pihole/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/stable/plex/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/stable/prometheus/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/stable/storj/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/stable/syncthing/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/stable/wg-easy/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 ix-dev/test/ix-remote-assist/app.yaml | 6 +- .../library/base_v2_1_8/container.py | 388 ----------------- .../templates/library/base_v2_1_8/ports.py | 68 --- .../base_v2_1_8/tests/test_container.py | 361 ---------------- .../library/base_v2_1_8/tests/test_ports.py | 110 ----- .../{base_v2_1_8 => base_v2_1_9}/__init__.py | 0 .../{base_v2_1_8 => base_v2_1_9}/configs.py | 0 .../library/base_v2_1_9/container.py | 391 ++++++++++++++++++ .../{base_v2_1_8 => base_v2_1_9}/depends.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deploy.py | 0 .../{base_v2_1_8 => base_v2_1_9}/deps.py | 0 .../deps_mariadb.py | 0 .../deps_perms.py | 0 .../deps_postgres.py | 0 .../deps_redis.py | 0 .../{base_v2_1_8 => base_v2_1_9}/device.py | 0 .../{base_v2_1_8 => base_v2_1_9}/devices.py | 0 .../{base_v2_1_8 => base_v2_1_9}/dns.py | 0 .../environment.py | 0 .../{base_v2_1_8 => base_v2_1_9}/error.py | 0 .../{base_v2_1_8 => base_v2_1_9}/expose.py | 0 .../{base_v2_1_8 => base_v2_1_9}/formatter.py | 0 .../{base_v2_1_8 => base_v2_1_9}/functions.py | 0 .../healthcheck.py | 0 .../{base_v2_1_8 => base_v2_1_9}/labels.py | 0 .../{base_v2_1_8 => base_v2_1_9}/notes.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portal.py | 0 .../{base_v2_1_8 => base_v2_1_9}/portals.py | 0 .../templates/library/base_v2_1_9/ports.py | 153 +++++++ .../{base_v2_1_8 => base_v2_1_9}/render.py | 0 .../{base_v2_1_8 => base_v2_1_9}/resources.py | 0 .../{base_v2_1_8 => base_v2_1_9}/restart.py | 0 .../{base_v2_1_8 => base_v2_1_9}/storage.py | 0 .../{base_v2_1_8 => base_v2_1_9}/sysctls.py | 0 .../tests/__init__.py | 0 .../tests/test_build_image.py | 0 .../tests/test_configs.py | 0 .../base_v2_1_9/tests/test_container.py | 369 +++++++++++++++++ .../tests/test_depends.py | 0 .../tests/test_deps.py | 0 .../tests/test_device.py | 0 .../tests/test_dns.py | 0 .../tests/test_environment.py | 0 .../tests/test_expose.py | 0 .../tests/test_formatter.py | 0 .../tests/test_functions.py | 0 .../tests/test_healthcheck.py | 0 .../tests/test_labels.py | 0 .../tests/test_notes.py | 0 .../tests/test_portal.py | 0 .../library/base_v2_1_9/tests/test_ports.py | 209 ++++++++++ .../tests/test_render.py | 0 .../tests/test_resources.py | 0 .../tests/test_restart.py | 0 .../tests/test_sysctls.py | 0 .../tests/test_validations.py | 0 .../tests/test_volumes.py | 0 .../validations.py | 0 .../volume_mount.py | 0 .../volume_mount_types.py | 0 .../volume_sources.py | 0 .../volume_types.py | 0 .../{base_v2_1_8 => base_v2_1_9}/volumes.py | 0 8436 files changed, 147774 insertions(+), 121644 deletions(-) delete mode 100644 ix-dev/community/actual-budget/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/actual-budget/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/actual-budget/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/actual-budget/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) rename ix-dev/community/actual-budget/templates/library/{base_v2_1_8 => base_v2_1_9}/container.py (97%) rename ix-dev/community/actual-budget/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/actual-budget/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/actual-budget/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/actual-budget/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/actual-budget/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/actual-budget/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/actual-budget/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/actual-budget/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/actual-budget/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/actual-budget/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/actual-budget/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/actual-budget/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/actual-budget/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/actual-budget/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/actual-budget/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/actual-budget/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/actual-budget/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/actual-budget/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/actual-budget/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/actual-budget/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/actual-budget/templates/library/base_v2_1_9/ports.py rename ix-dev/community/actual-budget/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/actual-budget/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/actual-budget/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/actual-budget/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/actual-budget/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/actual-budget/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/actual-budget/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/actual-budget/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) rename ix-dev/community/{audiobookshelf/templates/library/base_v2_1_8 => actual-budget/templates/library/base_v2_1_9}/tests/test_container.py (96%) rename ix-dev/community/actual-budget/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/actual-budget/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/actual-budget/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/actual-budget/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/actual-budget/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/actual-budget/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/actual-budget/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/actual-budget/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/actual-budget/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/actual-budget/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/actual-budget/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/actual-budget/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/actual-budget/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/actual-budget/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/actual-budget/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/actual-budget/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/actual-budget/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/actual-budget/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/actual-budget/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/actual-budget/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/actual-budget/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/actual-budget/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/actual-budget/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/actual-budget/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/actual-budget/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/adguard-home/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/adguard-home/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/adguard-home/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/adguard-home/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) rename ix-dev/community/adguard-home/templates/library/{base_v2_1_8 => base_v2_1_9}/container.py (97%) rename ix-dev/community/adguard-home/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/adguard-home/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/adguard-home/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/adguard-home/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/adguard-home/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/adguard-home/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/adguard-home/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/adguard-home/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/adguard-home/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/adguard-home/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/adguard-home/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/adguard-home/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/adguard-home/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/adguard-home/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/adguard-home/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/adguard-home/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/adguard-home/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/adguard-home/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/adguard-home/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/adguard-home/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/adguard-home/templates/library/base_v2_1_9/ports.py rename ix-dev/community/adguard-home/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/adguard-home/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/adguard-home/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/adguard-home/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/adguard-home/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/adguard-home/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/adguard-home/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/adguard-home/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) rename ix-dev/community/{autobrr/templates/library/base_v2_1_8 => adguard-home/templates/library/base_v2_1_9}/tests/test_container.py (96%) rename ix-dev/community/adguard-home/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/adguard-home/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/adguard-home/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/adguard-home/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/adguard-home/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/adguard-home/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/adguard-home/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/adguard-home/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/adguard-home/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/adguard-home/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/adguard-home/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/adguard-home/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/adguard-home/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/adguard-home/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/adguard-home/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/adguard-home/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/adguard-home/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/adguard-home/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/adguard-home/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/adguard-home/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/adguard-home/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/adguard-home/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/adguard-home/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/adguard-home/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/adguard-home/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/audiobookshelf/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/audiobookshelf/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) rename ix-dev/community/audiobookshelf/templates/library/{base_v2_1_8 => base_v2_1_9}/container.py (97%) rename ix-dev/community/audiobookshelf/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/audiobookshelf/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/audiobookshelf/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/audiobookshelf/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/audiobookshelf/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/audiobookshelf/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/audiobookshelf/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/audiobookshelf/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/audiobookshelf/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/audiobookshelf/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/audiobookshelf/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/audiobookshelf/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/audiobookshelf/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/audiobookshelf/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/audiobookshelf/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/audiobookshelf/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/audiobookshelf/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/audiobookshelf/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/audiobookshelf/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/audiobookshelf/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/ports.py rename ix-dev/community/audiobookshelf/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/audiobookshelf/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/audiobookshelf/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/audiobookshelf/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/audiobookshelf/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/audiobookshelf/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/audiobookshelf/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/audiobookshelf/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) rename ix-dev/community/{actual-budget/templates/library/base_v2_1_8 => audiobookshelf/templates/library/base_v2_1_9}/tests/test_container.py (96%) rename ix-dev/community/audiobookshelf/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/audiobookshelf/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/audiobookshelf/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/audiobookshelf/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/audiobookshelf/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/audiobookshelf/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/audiobookshelf/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/audiobookshelf/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/audiobookshelf/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/audiobookshelf/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/audiobookshelf/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/audiobookshelf/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/audiobookshelf/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/audiobookshelf/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/audiobookshelf/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/audiobookshelf/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/audiobookshelf/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/audiobookshelf/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/audiobookshelf/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/audiobookshelf/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/audiobookshelf/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/audiobookshelf/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/audiobookshelf/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/audiobookshelf/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/autobrr/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/autobrr/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/autobrr/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/autobrr/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) rename ix-dev/community/autobrr/templates/library/{base_v2_1_8 => base_v2_1_9}/container.py (97%) rename ix-dev/community/autobrr/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/autobrr/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/autobrr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/autobrr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/autobrr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/autobrr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/autobrr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/autobrr/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/autobrr/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/autobrr/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/autobrr/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/autobrr/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/autobrr/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/autobrr/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/autobrr/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/autobrr/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/autobrr/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/autobrr/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/autobrr/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/autobrr/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/autobrr/templates/library/base_v2_1_9/ports.py rename ix-dev/community/autobrr/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/autobrr/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/autobrr/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/autobrr/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/autobrr/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/autobrr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/autobrr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/autobrr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) rename ix-dev/community/{adguard-home/templates/library/base_v2_1_8 => autobrr/templates/library/base_v2_1_9}/tests/test_container.py (96%) rename ix-dev/community/autobrr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/autobrr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/autobrr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/autobrr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/autobrr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/autobrr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/autobrr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/autobrr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/autobrr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/autobrr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/autobrr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/autobrr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/autobrr/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/autobrr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/autobrr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/autobrr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/autobrr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/autobrr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/autobrr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/autobrr/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/autobrr/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/autobrr/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/autobrr/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/autobrr/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/autobrr/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/bazarr/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/bazarr/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/bazarr/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/bazarr/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/bazarr/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/bazarr/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/bazarr/templates/library/base_v2_1_9/container.py rename ix-dev/community/bazarr/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/bazarr/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/bazarr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/bazarr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/bazarr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/bazarr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/bazarr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/bazarr/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/bazarr/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/bazarr/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/bazarr/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/bazarr/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/bazarr/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/bazarr/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/bazarr/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/bazarr/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/bazarr/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/bazarr/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/bazarr/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/bazarr/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/bazarr/templates/library/base_v2_1_9/ports.py rename ix-dev/community/bazarr/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/bazarr/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/bazarr/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/bazarr/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/bazarr/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/bazarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/bazarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/bazarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/bazarr/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/bazarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/bazarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/bazarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/bazarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/bazarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/bazarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/bazarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/bazarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/bazarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/bazarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/bazarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/bazarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/bazarr/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/bazarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/bazarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/bazarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/bazarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/bazarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/bazarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/bazarr/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/bazarr/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/bazarr/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/bazarr/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/bazarr/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/bazarr/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/briefkasten/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/briefkasten/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/briefkasten/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/briefkasten/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/briefkasten/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/briefkasten/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/briefkasten/templates/library/base_v2_1_9/container.py rename ix-dev/community/briefkasten/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/briefkasten/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/briefkasten/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/briefkasten/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/briefkasten/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/briefkasten/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/briefkasten/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/briefkasten/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/briefkasten/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/briefkasten/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/briefkasten/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/briefkasten/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/briefkasten/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/briefkasten/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/briefkasten/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/briefkasten/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/briefkasten/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/briefkasten/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/briefkasten/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/briefkasten/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/briefkasten/templates/library/base_v2_1_9/ports.py rename ix-dev/community/briefkasten/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/briefkasten/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/briefkasten/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/briefkasten/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/briefkasten/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/briefkasten/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/briefkasten/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/briefkasten/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/briefkasten/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/briefkasten/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/briefkasten/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/briefkasten/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/briefkasten/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/briefkasten/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/briefkasten/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/briefkasten/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/briefkasten/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/briefkasten/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/briefkasten/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/briefkasten/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/briefkasten/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/briefkasten/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/briefkasten/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/briefkasten/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/briefkasten/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/briefkasten/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/briefkasten/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/briefkasten/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/briefkasten/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/briefkasten/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/briefkasten/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/briefkasten/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/briefkasten/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/briefkasten/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/calibre-web/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/calibre-web/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/calibre-web/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/calibre-web/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/calibre-web/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/calibre-web/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/calibre-web/templates/library/base_v2_1_9/container.py rename ix-dev/community/calibre-web/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/calibre-web/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/calibre-web/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/calibre-web/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/calibre-web/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/calibre-web/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/calibre-web/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/calibre-web/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/calibre-web/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/calibre-web/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/calibre-web/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/calibre-web/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/calibre-web/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/calibre-web/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/calibre-web/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/calibre-web/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/calibre-web/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/calibre-web/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/calibre-web/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/calibre-web/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/calibre-web/templates/library/base_v2_1_9/ports.py rename ix-dev/community/calibre-web/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/calibre-web/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/calibre-web/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/calibre-web/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/calibre-web/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/calibre-web/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/calibre-web/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/calibre-web/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/calibre-web/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/calibre-web/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/calibre-web/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/calibre-web/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/calibre-web/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/calibre-web/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/calibre-web/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/calibre-web/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/calibre-web/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/calibre-web/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/calibre-web/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/calibre-web/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/calibre-web/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/calibre-web/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/calibre-web/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/calibre-web/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/calibre-web/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/calibre-web/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/calibre-web/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/calibre-web/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/calibre-web/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/calibre-web/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/calibre-web/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/calibre-web/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/calibre-web/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/calibre-web/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/calibre/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/calibre/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/calibre/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/calibre/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/calibre/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/calibre/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/calibre/templates/library/base_v2_1_9/container.py rename ix-dev/community/calibre/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/calibre/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/calibre/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/calibre/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/calibre/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/calibre/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/calibre/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/calibre/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/calibre/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/calibre/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/calibre/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/calibre/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/calibre/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/calibre/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/calibre/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/calibre/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/calibre/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/calibre/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/calibre/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/calibre/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/calibre/templates/library/base_v2_1_9/ports.py rename ix-dev/community/calibre/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/calibre/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/calibre/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/calibre/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/calibre/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/calibre/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/calibre/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/calibre/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/calibre/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/calibre/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/calibre/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/calibre/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/calibre/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/calibre/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/calibre/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/calibre/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/calibre/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/calibre/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/calibre/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/calibre/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/calibre/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/calibre/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/calibre/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/calibre/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/calibre/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/calibre/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/calibre/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/calibre/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/calibre/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/calibre/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/calibre/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/calibre/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/calibre/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/calibre/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/castopod/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/castopod/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/castopod/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/castopod/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/castopod/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/castopod/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/castopod/templates/library/base_v2_1_9/container.py rename ix-dev/community/castopod/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/castopod/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/castopod/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/castopod/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/castopod/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/castopod/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/castopod/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/castopod/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/castopod/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/castopod/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/castopod/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/castopod/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/castopod/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/castopod/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/castopod/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/castopod/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/castopod/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/castopod/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/castopod/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/castopod/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/castopod/templates/library/base_v2_1_9/ports.py rename ix-dev/community/castopod/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/castopod/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/castopod/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/castopod/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/castopod/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/castopod/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/castopod/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/castopod/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/castopod/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/castopod/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/castopod/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/castopod/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/castopod/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/castopod/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/castopod/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/castopod/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/castopod/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/castopod/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/castopod/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/castopod/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/castopod/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/castopod/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/castopod/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/castopod/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/castopod/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/castopod/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/castopod/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/castopod/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/castopod/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/castopod/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/castopod/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/castopod/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/castopod/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/castopod/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/chia/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/chia/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/chia/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/chia/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/chia/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/chia/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/chia/templates/library/base_v2_1_9/container.py rename ix-dev/community/chia/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/chia/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/chia/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/chia/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/chia/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/chia/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/chia/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/chia/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/chia/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/chia/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/chia/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/chia/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/chia/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/chia/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/chia/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/chia/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/chia/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/chia/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/chia/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/chia/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/chia/templates/library/base_v2_1_9/ports.py rename ix-dev/community/chia/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/chia/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/chia/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/chia/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/chia/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/chia/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/chia/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/chia/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/chia/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/chia/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/chia/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/chia/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/chia/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/chia/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/chia/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/chia/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/chia/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/chia/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/chia/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/chia/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/chia/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/chia/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/chia/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/chia/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/chia/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/chia/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/chia/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/chia/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/chia/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/chia/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/chia/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/chia/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/chia/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/chia/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/clamav/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/clamav/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/clamav/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/clamav/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/clamav/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/clamav/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/clamav/templates/library/base_v2_1_9/container.py rename ix-dev/community/clamav/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/clamav/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/clamav/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/clamav/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/clamav/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/clamav/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/clamav/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/clamav/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/clamav/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/clamav/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/clamav/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/clamav/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/clamav/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/clamav/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/clamav/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/clamav/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/clamav/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/clamav/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/clamav/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/clamav/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/clamav/templates/library/base_v2_1_9/ports.py rename ix-dev/community/clamav/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/clamav/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/clamav/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/clamav/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/clamav/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/clamav/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/clamav/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/clamav/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/clamav/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/clamav/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/clamav/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/clamav/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/clamav/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/clamav/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/clamav/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/clamav/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/clamav/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/clamav/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/clamav/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/clamav/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/clamav/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/clamav/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/clamav/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/clamav/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/clamav/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/clamav/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/clamav/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/clamav/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/clamav/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/clamav/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/clamav/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/clamav/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/clamav/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/clamav/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/cloudflared/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/cloudflared/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/cloudflared/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/cloudflared/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/cloudflared/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/cloudflared/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/cloudflared/templates/library/base_v2_1_9/container.py rename ix-dev/community/cloudflared/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/cloudflared/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/cloudflared/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/cloudflared/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/cloudflared/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/cloudflared/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/cloudflared/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/cloudflared/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/cloudflared/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/cloudflared/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/cloudflared/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/cloudflared/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/cloudflared/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/cloudflared/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/cloudflared/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/cloudflared/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/cloudflared/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/cloudflared/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/cloudflared/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/cloudflared/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/cloudflared/templates/library/base_v2_1_9/ports.py rename ix-dev/community/cloudflared/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/cloudflared/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/cloudflared/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/cloudflared/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/cloudflared/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/cloudflared/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/cloudflared/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/cloudflared/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/cloudflared/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/cloudflared/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/cloudflared/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/cloudflared/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/cloudflared/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/cloudflared/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/cloudflared/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/cloudflared/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/cloudflared/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/cloudflared/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/cloudflared/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/cloudflared/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/cloudflared/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/cloudflared/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/cloudflared/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/cloudflared/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/cloudflared/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/cloudflared/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/cloudflared/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/cloudflared/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/cloudflared/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/cloudflared/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/cloudflared/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/cloudflared/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/cloudflared/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/cloudflared/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/dashy/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/dashy/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/dashy/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/dashy/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/dashy/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/dashy/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/dashy/templates/library/base_v2_1_9/container.py rename ix-dev/community/dashy/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/dashy/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/dashy/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/dashy/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/dashy/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/dashy/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/dashy/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/dashy/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/dashy/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/dashy/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/dashy/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/dashy/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/dashy/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/dashy/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/dashy/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/dashy/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/dashy/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/dashy/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/dashy/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/dashy/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/dashy/templates/library/base_v2_1_9/ports.py rename ix-dev/community/dashy/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/dashy/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/dashy/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/dashy/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/dashy/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/dashy/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/dashy/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/dashy/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/dashy/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/dashy/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/dashy/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/dashy/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/dashy/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/dashy/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/dashy/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/dashy/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/dashy/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/dashy/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/dashy/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/dashy/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/dashy/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/dashy/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/dashy/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/dashy/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/dashy/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/dashy/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/dashy/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/dashy/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/dashy/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/dashy/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/dashy/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/dashy/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/dashy/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/dashy/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/ddns-updater/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/ddns-updater/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/ddns-updater/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/ddns-updater/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/ddns-updater/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/ddns-updater/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/ddns-updater/templates/library/base_v2_1_9/container.py rename ix-dev/community/ddns-updater/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/ddns-updater/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/ddns-updater/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/ddns-updater/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/ddns-updater/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/ddns-updater/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/ddns-updater/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/ddns-updater/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/ddns-updater/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/ddns-updater/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/ddns-updater/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/ddns-updater/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/ddns-updater/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/ddns-updater/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/ddns-updater/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/ddns-updater/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/ddns-updater/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/ddns-updater/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/ddns-updater/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/ddns-updater/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/ddns-updater/templates/library/base_v2_1_9/ports.py rename ix-dev/community/ddns-updater/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/ddns-updater/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/ddns-updater/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/ddns-updater/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/ddns-updater/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/ddns-updater/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/ddns-updater/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/ddns-updater/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/ddns-updater/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/ddns-updater/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/ddns-updater/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/ddns-updater/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/ddns-updater/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/ddns-updater/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/ddns-updater/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/ddns-updater/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/ddns-updater/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/ddns-updater/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/ddns-updater/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/ddns-updater/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/ddns-updater/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/ddns-updater/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/ddns-updater/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/ddns-updater/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/ddns-updater/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/ddns-updater/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/ddns-updater/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/ddns-updater/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/ddns-updater/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/ddns-updater/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/ddns-updater/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/ddns-updater/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/ddns-updater/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/ddns-updater/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) rename ix-dev/community/ddns-updater/templates/library/community/ddns-updater/{v1_1_8 => v1_1_9}/__init__.py (100%) rename ix-dev/community/ddns-updater/templates/library/community/ddns-updater/{v1_1_8 => v1_1_9}/config.py (100%) delete mode 100644 ix-dev/community/deluge/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/deluge/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/deluge/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/deluge/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/deluge/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/deluge/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/deluge/templates/library/base_v2_1_9/container.py rename ix-dev/community/deluge/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/deluge/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/deluge/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/deluge/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/deluge/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/deluge/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/deluge/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/deluge/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/deluge/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/deluge/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/deluge/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/deluge/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/deluge/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/deluge/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/deluge/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/deluge/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/deluge/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/deluge/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/deluge/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/deluge/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/deluge/templates/library/base_v2_1_9/ports.py rename ix-dev/community/deluge/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/deluge/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/deluge/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/deluge/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/deluge/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/deluge/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/deluge/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/deluge/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/deluge/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/deluge/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/deluge/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/deluge/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/deluge/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/deluge/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/deluge/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/deluge/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/deluge/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/deluge/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/deluge/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/deluge/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/deluge/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/deluge/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/deluge/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/deluge/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/deluge/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/deluge/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/deluge/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/deluge/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/deluge/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/deluge/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/deluge/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/deluge/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/deluge/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/deluge/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/distribution/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/distribution/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/distribution/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/distribution/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/distribution/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/distribution/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/distribution/templates/library/base_v2_1_9/container.py rename ix-dev/community/distribution/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/distribution/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/distribution/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/distribution/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/distribution/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/distribution/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/distribution/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/distribution/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/distribution/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/distribution/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/distribution/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/distribution/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/distribution/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/distribution/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/distribution/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/distribution/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/distribution/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/distribution/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/distribution/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/distribution/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/distribution/templates/library/base_v2_1_9/ports.py rename ix-dev/community/distribution/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/distribution/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/distribution/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/distribution/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/distribution/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/distribution/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/distribution/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/distribution/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/distribution/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/distribution/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/distribution/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/distribution/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/distribution/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/distribution/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/distribution/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/distribution/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/distribution/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/distribution/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/distribution/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/distribution/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/distribution/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/distribution/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/distribution/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/distribution/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/distribution/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/distribution/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/distribution/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/distribution/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/distribution/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/distribution/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/distribution/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/distribution/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/distribution/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/distribution/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/dockge/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/dockge/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/dockge/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/dockge/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/dockge/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/dockge/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/dockge/templates/library/base_v2_1_9/container.py rename ix-dev/community/dockge/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/dockge/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/dockge/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/dockge/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/dockge/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/dockge/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/dockge/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/dockge/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/dockge/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/dockge/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/dockge/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/dockge/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/dockge/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/dockge/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/dockge/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/dockge/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/dockge/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/dockge/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/dockge/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/dockge/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/dockge/templates/library/base_v2_1_9/ports.py rename ix-dev/community/dockge/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/dockge/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/dockge/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/dockge/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/dockge/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/dockge/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/dockge/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/dockge/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/dockge/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/dockge/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/dockge/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/dockge/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/dockge/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/dockge/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/dockge/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/dockge/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/dockge/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/dockge/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/dockge/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/dockge/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/dockge/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/dockge/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/dockge/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/dockge/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/dockge/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/dockge/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/dockge/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/dockge/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/dockge/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/dockge/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/dockge/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/dockge/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/dockge/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/dockge/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/drawio/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/drawio/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/drawio/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/drawio/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/drawio/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/drawio/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/drawio/templates/library/base_v2_1_9/container.py rename ix-dev/community/drawio/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/drawio/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/drawio/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/drawio/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/drawio/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/drawio/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/drawio/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/drawio/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/drawio/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/drawio/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/drawio/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/drawio/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/drawio/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/drawio/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/drawio/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/drawio/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/drawio/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/drawio/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/drawio/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/drawio/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/drawio/templates/library/base_v2_1_9/ports.py rename ix-dev/community/drawio/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/drawio/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/drawio/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/drawio/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/drawio/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/drawio/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/drawio/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/drawio/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/drawio/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/drawio/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/drawio/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/drawio/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/drawio/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/drawio/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/drawio/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/drawio/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/drawio/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/drawio/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/drawio/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/drawio/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/drawio/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/drawio/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/drawio/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/drawio/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/drawio/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/drawio/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/drawio/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/drawio/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/drawio/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/drawio/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/drawio/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/drawio/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/drawio/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/drawio/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/eclipse-mosquitto/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/eclipse-mosquitto/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/container.py rename ix-dev/community/eclipse-mosquitto/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/eclipse-mosquitto/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/eclipse-mosquitto/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/eclipse-mosquitto/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/eclipse-mosquitto/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/eclipse-mosquitto/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/eclipse-mosquitto/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/eclipse-mosquitto/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/eclipse-mosquitto/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/eclipse-mosquitto/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/eclipse-mosquitto/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/eclipse-mosquitto/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/eclipse-mosquitto/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/eclipse-mosquitto/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/eclipse-mosquitto/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/eclipse-mosquitto/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/eclipse-mosquitto/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/eclipse-mosquitto/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/eclipse-mosquitto/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/eclipse-mosquitto/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/ports.py rename ix-dev/community/eclipse-mosquitto/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/eclipse-mosquitto/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/eclipse-mosquitto/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/eclipse-mosquitto/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/eclipse-mosquitto/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/eclipse-mosquitto/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/eclipse-mosquitto/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/eclipse-mosquitto/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/eclipse-mosquitto/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/eclipse-mosquitto/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/eclipse-mosquitto/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/eclipse-mosquitto/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/eclipse-mosquitto/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/eclipse-mosquitto/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/eclipse-mosquitto/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/eclipse-mosquitto/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/eclipse-mosquitto/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/eclipse-mosquitto/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/eclipse-mosquitto/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/eclipse-mosquitto/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/eclipse-mosquitto/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/eclipse-mosquitto/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/eclipse-mosquitto/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/eclipse-mosquitto/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/eclipse-mosquitto/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/eclipse-mosquitto/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/eclipse-mosquitto/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/eclipse-mosquitto/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/eclipse-mosquitto/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/eclipse-mosquitto/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/eclipse-mosquitto/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/eclipse-mosquitto/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/filebrowser/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/filebrowser/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/filebrowser/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/filebrowser/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/filebrowser/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/filebrowser/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/filebrowser/templates/library/base_v2_1_9/container.py rename ix-dev/community/filebrowser/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/filebrowser/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/filebrowser/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/filebrowser/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/filebrowser/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/filebrowser/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/filebrowser/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/filebrowser/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/filebrowser/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/filebrowser/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/filebrowser/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/filebrowser/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/filebrowser/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/filebrowser/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/filebrowser/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/filebrowser/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/filebrowser/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/filebrowser/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/filebrowser/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/filebrowser/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/filebrowser/templates/library/base_v2_1_9/ports.py rename ix-dev/community/filebrowser/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/filebrowser/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/filebrowser/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/filebrowser/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/filebrowser/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/filebrowser/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/filebrowser/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/filebrowser/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/filebrowser/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/filebrowser/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/filebrowser/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/filebrowser/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/filebrowser/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/filebrowser/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/filebrowser/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/filebrowser/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/filebrowser/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/filebrowser/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/filebrowser/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/filebrowser/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/filebrowser/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/filebrowser/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/filebrowser/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/filebrowser/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/filebrowser/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/filebrowser/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/filebrowser/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/filebrowser/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/filebrowser/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/filebrowser/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/filebrowser/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/filebrowser/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/filebrowser/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/filebrowser/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/filestash/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/filestash/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/filestash/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/filestash/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/filestash/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/filestash/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/filestash/templates/library/base_v2_1_9/container.py rename ix-dev/community/filestash/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/filestash/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/filestash/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/filestash/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/filestash/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/filestash/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/filestash/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/filestash/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/filestash/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/filestash/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/filestash/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/filestash/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/filestash/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/filestash/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/filestash/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/filestash/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/filestash/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/filestash/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/filestash/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/filestash/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/filestash/templates/library/base_v2_1_9/ports.py rename ix-dev/community/filestash/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/filestash/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/filestash/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/filestash/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/filestash/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/filestash/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/filestash/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/filestash/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/filestash/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/filestash/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/filestash/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/filestash/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/filestash/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/filestash/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/filestash/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/filestash/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/filestash/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/filestash/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/filestash/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/filestash/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/filestash/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/filestash/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/filestash/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/filestash/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/filestash/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/filestash/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/filestash/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/filestash/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/filestash/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/filestash/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/filestash/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/filestash/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/filestash/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/filestash/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/firefly-iii/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/firefly-iii/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/firefly-iii/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/firefly-iii/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/firefly-iii/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/firefly-iii/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/firefly-iii/templates/library/base_v2_1_9/container.py rename ix-dev/community/firefly-iii/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/firefly-iii/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/firefly-iii/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/firefly-iii/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/firefly-iii/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/firefly-iii/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/firefly-iii/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/firefly-iii/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/firefly-iii/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/firefly-iii/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/firefly-iii/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/firefly-iii/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/firefly-iii/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/firefly-iii/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/firefly-iii/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/firefly-iii/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/firefly-iii/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/firefly-iii/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/firefly-iii/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/firefly-iii/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/firefly-iii/templates/library/base_v2_1_9/ports.py rename ix-dev/community/firefly-iii/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/firefly-iii/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/firefly-iii/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/firefly-iii/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/firefly-iii/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/firefly-iii/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/firefly-iii/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/firefly-iii/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/firefly-iii/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/firefly-iii/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/firefly-iii/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/firefly-iii/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/firefly-iii/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/firefly-iii/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/firefly-iii/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/firefly-iii/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/firefly-iii/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/firefly-iii/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/firefly-iii/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/firefly-iii/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/firefly-iii/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/firefly-iii/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/firefly-iii/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/firefly-iii/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/firefly-iii/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/firefly-iii/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/firefly-iii/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/firefly-iii/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/firefly-iii/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/firefly-iii/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/firefly-iii/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/firefly-iii/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/firefly-iii/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/firefly-iii/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/flame/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/flame/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/flame/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/flame/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/flame/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/flame/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/flame/templates/library/base_v2_1_9/container.py rename ix-dev/community/flame/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/flame/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/flame/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/flame/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/flame/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/flame/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/flame/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/flame/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/flame/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/flame/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/flame/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/flame/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/flame/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/flame/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/flame/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/flame/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/flame/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/flame/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/flame/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/flame/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/flame/templates/library/base_v2_1_9/ports.py rename ix-dev/community/flame/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/flame/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/flame/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/flame/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/flame/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/flame/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/flame/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/flame/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/flame/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/flame/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/flame/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/flame/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/flame/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/flame/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/flame/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/flame/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/flame/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/flame/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/flame/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/flame/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/flame/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/flame/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/flame/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/flame/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/flame/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/flame/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/flame/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/flame/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/flame/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/flame/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/flame/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/flame/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/flame/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/flame/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/flaresolverr/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/flaresolverr/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/flaresolverr/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/flaresolverr/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/flaresolverr/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/flaresolverr/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/flaresolverr/templates/library/base_v2_1_9/container.py rename ix-dev/community/flaresolverr/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/flaresolverr/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/flaresolverr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/flaresolverr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/flaresolverr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/flaresolverr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/flaresolverr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/flaresolverr/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/flaresolverr/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/flaresolverr/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/flaresolverr/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/flaresolverr/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/flaresolverr/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/flaresolverr/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/flaresolverr/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/flaresolverr/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/flaresolverr/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/flaresolverr/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/flaresolverr/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/flaresolverr/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/flaresolverr/templates/library/base_v2_1_9/ports.py rename ix-dev/community/flaresolverr/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/flaresolverr/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/flaresolverr/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/flaresolverr/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/flaresolverr/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/flaresolverr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/flaresolverr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/flaresolverr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/flaresolverr/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/flaresolverr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/flaresolverr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/flaresolverr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/flaresolverr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/flaresolverr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/flaresolverr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/flaresolverr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/flaresolverr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/flaresolverr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/flaresolverr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/flaresolverr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/flaresolverr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/flaresolverr/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/flaresolverr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/flaresolverr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/flaresolverr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/flaresolverr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/flaresolverr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/flaresolverr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/flaresolverr/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/flaresolverr/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/flaresolverr/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/flaresolverr/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/flaresolverr/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/flaresolverr/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/freshrss/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/freshrss/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/freshrss/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/freshrss/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/freshrss/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/freshrss/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/freshrss/templates/library/base_v2_1_9/container.py rename ix-dev/community/freshrss/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/freshrss/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/freshrss/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/freshrss/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/freshrss/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/freshrss/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/freshrss/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/freshrss/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/freshrss/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/freshrss/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/freshrss/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/freshrss/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/freshrss/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/freshrss/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/freshrss/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/freshrss/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/freshrss/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/freshrss/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/freshrss/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/freshrss/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/freshrss/templates/library/base_v2_1_9/ports.py rename ix-dev/community/freshrss/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/freshrss/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/freshrss/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/freshrss/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/freshrss/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/freshrss/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/freshrss/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/freshrss/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/freshrss/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/freshrss/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/freshrss/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/freshrss/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/freshrss/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/freshrss/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/freshrss/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/freshrss/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/freshrss/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/freshrss/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/freshrss/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/freshrss/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/freshrss/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/freshrss/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/freshrss/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/freshrss/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/freshrss/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/freshrss/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/freshrss/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/freshrss/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/freshrss/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/freshrss/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/freshrss/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/freshrss/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/freshrss/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/freshrss/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/frigate/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/frigate/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/frigate/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/frigate/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/frigate/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/frigate/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/frigate/templates/library/base_v2_1_9/container.py rename ix-dev/community/frigate/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/frigate/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/frigate/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/frigate/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/frigate/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/frigate/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/frigate/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/frigate/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/frigate/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/frigate/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/frigate/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/frigate/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/frigate/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/frigate/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/frigate/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/frigate/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/frigate/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/frigate/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/frigate/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/frigate/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/frigate/templates/library/base_v2_1_9/ports.py rename ix-dev/community/frigate/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/frigate/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/frigate/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/frigate/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/frigate/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/frigate/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/frigate/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/frigate/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/frigate/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/frigate/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/frigate/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/frigate/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/frigate/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/frigate/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/frigate/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/frigate/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/frigate/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/frigate/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/frigate/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/frigate/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/frigate/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/frigate/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/frigate/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/frigate/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/frigate/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/frigate/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/frigate/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/frigate/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/frigate/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/frigate/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/frigate/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/frigate/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/frigate/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/frigate/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/fscrawler/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/fscrawler/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/fscrawler/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/fscrawler/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/fscrawler/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/fscrawler/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/fscrawler/templates/library/base_v2_1_9/container.py rename ix-dev/community/fscrawler/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/fscrawler/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/fscrawler/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/fscrawler/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/fscrawler/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/fscrawler/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/fscrawler/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/fscrawler/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/fscrawler/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/fscrawler/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/fscrawler/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/fscrawler/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/fscrawler/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/fscrawler/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/fscrawler/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/fscrawler/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/fscrawler/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/fscrawler/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/fscrawler/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/fscrawler/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/fscrawler/templates/library/base_v2_1_9/ports.py rename ix-dev/community/fscrawler/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/fscrawler/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/fscrawler/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/fscrawler/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/fscrawler/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/fscrawler/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/fscrawler/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/fscrawler/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/fscrawler/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/fscrawler/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/fscrawler/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/fscrawler/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/fscrawler/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/fscrawler/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/fscrawler/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/fscrawler/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/fscrawler/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/fscrawler/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/fscrawler/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/fscrawler/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/fscrawler/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/fscrawler/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/fscrawler/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/fscrawler/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/fscrawler/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/fscrawler/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/fscrawler/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/fscrawler/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/fscrawler/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/fscrawler/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/fscrawler/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/fscrawler/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/fscrawler/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/fscrawler/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/gaseous-server/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/gaseous-server/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/gaseous-server/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/gaseous-server/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/gaseous-server/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/gaseous-server/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/gaseous-server/templates/library/base_v2_1_9/container.py rename ix-dev/community/gaseous-server/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/gaseous-server/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/gaseous-server/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/gaseous-server/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/gaseous-server/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/gaseous-server/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/gaseous-server/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/gaseous-server/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/gaseous-server/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/gaseous-server/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/gaseous-server/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/gaseous-server/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/gaseous-server/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/gaseous-server/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/gaseous-server/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/gaseous-server/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/gaseous-server/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/gaseous-server/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/gaseous-server/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/gaseous-server/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/gaseous-server/templates/library/base_v2_1_9/ports.py rename ix-dev/community/gaseous-server/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/gaseous-server/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/gaseous-server/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/gaseous-server/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/gaseous-server/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/gaseous-server/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/gaseous-server/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/gaseous-server/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/gaseous-server/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/gaseous-server/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/gaseous-server/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/gaseous-server/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/gaseous-server/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/gaseous-server/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/gaseous-server/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/gaseous-server/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/gaseous-server/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/gaseous-server/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/gaseous-server/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/gaseous-server/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/gaseous-server/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/gaseous-server/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/gaseous-server/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/gaseous-server/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/gaseous-server/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/gaseous-server/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/gaseous-server/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/gaseous-server/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/gaseous-server/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/gaseous-server/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/gaseous-server/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/gaseous-server/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/gaseous-server/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/gaseous-server/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/gitea/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/gitea/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/gitea/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/gitea/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/gitea/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/gitea/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/gitea/templates/library/base_v2_1_9/container.py rename ix-dev/community/gitea/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/gitea/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/gitea/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/gitea/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/gitea/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/gitea/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/gitea/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/gitea/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/gitea/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/gitea/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/gitea/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/gitea/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/gitea/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/gitea/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/gitea/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/gitea/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/gitea/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/gitea/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/gitea/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/gitea/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/gitea/templates/library/base_v2_1_9/ports.py rename ix-dev/community/gitea/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/gitea/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/gitea/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/gitea/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/gitea/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/gitea/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/gitea/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/gitea/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/gitea/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/gitea/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/gitea/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/gitea/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/gitea/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/gitea/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/gitea/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/gitea/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/gitea/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/gitea/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/gitea/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/gitea/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/gitea/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/gitea/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/gitea/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/gitea/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/gitea/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/gitea/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/gitea/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/gitea/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/gitea/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/gitea/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/gitea/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/gitea/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/gitea/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/gitea/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/grafana/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/grafana/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/grafana/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/grafana/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/grafana/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/grafana/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/grafana/templates/library/base_v2_1_9/container.py rename ix-dev/community/grafana/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/grafana/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/grafana/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/grafana/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/grafana/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/grafana/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/grafana/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/grafana/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/grafana/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/grafana/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/grafana/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/grafana/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/grafana/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/grafana/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/grafana/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/grafana/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/grafana/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/grafana/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/grafana/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/grafana/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/grafana/templates/library/base_v2_1_9/ports.py rename ix-dev/community/grafana/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/grafana/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/grafana/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/grafana/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/grafana/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/grafana/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/grafana/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/grafana/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/grafana/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/grafana/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/grafana/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/grafana/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/grafana/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/grafana/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/grafana/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/grafana/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/grafana/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/grafana/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/grafana/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/grafana/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/grafana/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/grafana/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/grafana/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/grafana/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/grafana/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/grafana/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/grafana/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/grafana/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/grafana/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/grafana/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/grafana/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/grafana/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/grafana/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/grafana/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/handbrake/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/handbrake/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/handbrake/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/handbrake/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/handbrake/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/handbrake/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/handbrake/templates/library/base_v2_1_9/container.py rename ix-dev/community/handbrake/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/handbrake/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/handbrake/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/handbrake/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/handbrake/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/handbrake/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/handbrake/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/handbrake/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/handbrake/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/handbrake/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/handbrake/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/handbrake/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/handbrake/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/handbrake/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/handbrake/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/handbrake/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/handbrake/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/handbrake/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/handbrake/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/handbrake/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/handbrake/templates/library/base_v2_1_9/ports.py rename ix-dev/community/handbrake/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/handbrake/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/handbrake/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/handbrake/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/handbrake/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/handbrake/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/handbrake/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/handbrake/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/handbrake/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/handbrake/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/handbrake/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/handbrake/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/handbrake/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/handbrake/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/handbrake/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/handbrake/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/handbrake/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/handbrake/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/handbrake/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/handbrake/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/handbrake/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/handbrake/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/handbrake/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/handbrake/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/handbrake/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/handbrake/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/handbrake/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/handbrake/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/handbrake/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/handbrake/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/handbrake/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/handbrake/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/handbrake/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/handbrake/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/homarr/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/homarr/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/homarr/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/homarr/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/homarr/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/homarr/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/homarr/templates/library/base_v2_1_9/container.py rename ix-dev/community/homarr/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/homarr/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/homarr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/homarr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/homarr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/homarr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/homarr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/homarr/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/homarr/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/homarr/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/homarr/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/homarr/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/homarr/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/homarr/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/homarr/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/homarr/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/homarr/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/homarr/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/homarr/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/homarr/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/homarr/templates/library/base_v2_1_9/ports.py rename ix-dev/community/homarr/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/homarr/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/homarr/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/homarr/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/homarr/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/homarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/homarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/homarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/homarr/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/homarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/homarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/homarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/homarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/homarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/homarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/homarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/homarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/homarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/homarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/homarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/homarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/homarr/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/homarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/homarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/homarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/homarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/homarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/homarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/homarr/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/homarr/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/homarr/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/homarr/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/homarr/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/homarr/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/homepage/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/homepage/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/homepage/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/homepage/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/homepage/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/homepage/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/homepage/templates/library/base_v2_1_9/container.py rename ix-dev/community/homepage/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/homepage/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/homepage/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/homepage/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/homepage/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/homepage/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/homepage/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/homepage/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/homepage/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/homepage/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/homepage/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/homepage/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/homepage/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/homepage/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/homepage/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/homepage/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/homepage/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/homepage/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/homepage/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/homepage/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/homepage/templates/library/base_v2_1_9/ports.py rename ix-dev/community/homepage/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/homepage/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/homepage/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/homepage/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/homepage/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/homepage/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/homepage/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/homepage/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/homepage/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/homepage/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/homepage/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/homepage/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/homepage/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/homepage/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/homepage/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/homepage/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/homepage/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/homepage/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/homepage/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/homepage/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/homepage/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/homepage/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/homepage/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/homepage/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/homepage/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/homepage/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/homepage/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/homepage/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/homepage/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/homepage/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/homepage/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/homepage/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/homepage/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/homepage/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/homer/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/homer/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/homer/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/homer/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/homer/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/homer/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/homer/templates/library/base_v2_1_9/container.py rename ix-dev/community/homer/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/homer/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/homer/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/homer/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/homer/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/homer/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/homer/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/homer/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/homer/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/homer/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/homer/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/homer/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/homer/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/homer/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/homer/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/homer/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/homer/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/homer/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/homer/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/homer/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/homer/templates/library/base_v2_1_9/ports.py rename ix-dev/community/homer/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/homer/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/homer/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/homer/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/homer/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/homer/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/homer/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/homer/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/homer/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/homer/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/homer/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/homer/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/homer/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/homer/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/homer/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/homer/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/homer/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/homer/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/homer/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/homer/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/homer/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/homer/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/homer/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/homer/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/homer/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/homer/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/homer/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/homer/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/homer/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/homer/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/homer/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/homer/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/homer/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/homer/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/iconik-storage-gateway/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/iconik-storage-gateway/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/container.py rename ix-dev/community/iconik-storage-gateway/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/iconik-storage-gateway/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/iconik-storage-gateway/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/iconik-storage-gateway/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/iconik-storage-gateway/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/iconik-storage-gateway/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/iconik-storage-gateway/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/iconik-storage-gateway/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/iconik-storage-gateway/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/iconik-storage-gateway/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/iconik-storage-gateway/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/iconik-storage-gateway/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/iconik-storage-gateway/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/iconik-storage-gateway/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/iconik-storage-gateway/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/iconik-storage-gateway/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/iconik-storage-gateway/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/iconik-storage-gateway/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/iconik-storage-gateway/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/iconik-storage-gateway/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/ports.py rename ix-dev/community/iconik-storage-gateway/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/iconik-storage-gateway/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/iconik-storage-gateway/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/iconik-storage-gateway/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/iconik-storage-gateway/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/iconik-storage-gateway/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/iconik-storage-gateway/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/iconik-storage-gateway/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/iconik-storage-gateway/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/iconik-storage-gateway/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/iconik-storage-gateway/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/iconik-storage-gateway/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/iconik-storage-gateway/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/iconik-storage-gateway/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/iconik-storage-gateway/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/iconik-storage-gateway/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/iconik-storage-gateway/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/iconik-storage-gateway/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/iconik-storage-gateway/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/iconik-storage-gateway/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/iconik-storage-gateway/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/iconik-storage-gateway/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/iconik-storage-gateway/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/iconik-storage-gateway/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/iconik-storage-gateway/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/iconik-storage-gateway/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/iconik-storage-gateway/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/iconik-storage-gateway/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/iconik-storage-gateway/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/iconik-storage-gateway/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/iconik-storage-gateway/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/iconik-storage-gateway/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/immich/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/immich/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/immich/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/immich/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/immich/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/immich/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/immich/templates/library/base_v2_1_9/container.py rename ix-dev/community/immich/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/immich/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/immich/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/immich/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/immich/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/immich/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/immich/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/immich/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/immich/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/immich/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/immich/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/immich/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/immich/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/immich/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/immich/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/immich/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/immich/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/immich/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/immich/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/immich/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/immich/templates/library/base_v2_1_9/ports.py rename ix-dev/community/immich/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/immich/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/immich/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/immich/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/immich/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/immich/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/immich/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/immich/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/immich/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/immich/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/immich/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/immich/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/immich/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/immich/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/immich/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/immich/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/immich/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/immich/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/immich/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/immich/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/immich/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/immich/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/immich/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/immich/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/immich/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/immich/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/immich/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/immich/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/immich/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/immich/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/immich/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/immich/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/immich/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/immich/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/invidious/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/invidious/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/invidious/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/invidious/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/invidious/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/invidious/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/invidious/templates/library/base_v2_1_9/container.py rename ix-dev/community/invidious/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/invidious/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/invidious/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/invidious/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/invidious/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/invidious/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/invidious/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/invidious/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/invidious/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/invidious/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/invidious/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/invidious/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/invidious/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/invidious/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/invidious/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/invidious/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/invidious/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/invidious/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/invidious/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/invidious/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/invidious/templates/library/base_v2_1_9/ports.py rename ix-dev/community/invidious/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/invidious/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/invidious/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/invidious/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/invidious/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/invidious/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/invidious/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/invidious/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/invidious/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/invidious/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/invidious/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/invidious/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/invidious/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/invidious/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/invidious/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/invidious/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/invidious/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/invidious/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/invidious/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/invidious/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/invidious/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/invidious/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/invidious/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/invidious/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/invidious/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/invidious/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/invidious/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/invidious/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/invidious/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/invidious/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/invidious/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/invidious/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/invidious/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/invidious/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/invoice-ninja/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/invoice-ninja/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/container.py rename ix-dev/community/invoice-ninja/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/invoice-ninja/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/invoice-ninja/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/invoice-ninja/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/invoice-ninja/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/invoice-ninja/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/invoice-ninja/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/invoice-ninja/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/invoice-ninja/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/invoice-ninja/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/invoice-ninja/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/invoice-ninja/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/invoice-ninja/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/invoice-ninja/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/invoice-ninja/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/invoice-ninja/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/invoice-ninja/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/invoice-ninja/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/invoice-ninja/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/invoice-ninja/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/ports.py rename ix-dev/community/invoice-ninja/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/invoice-ninja/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/invoice-ninja/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/invoice-ninja/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/invoice-ninja/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/invoice-ninja/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/invoice-ninja/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/invoice-ninja/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/invoice-ninja/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/invoice-ninja/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/invoice-ninja/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/invoice-ninja/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/invoice-ninja/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/invoice-ninja/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/invoice-ninja/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/invoice-ninja/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/invoice-ninja/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/invoice-ninja/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/invoice-ninja/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/invoice-ninja/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/invoice-ninja/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/invoice-ninja/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/invoice-ninja/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/invoice-ninja/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/invoice-ninja/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/invoice-ninja/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/invoice-ninja/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/invoice-ninja/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/invoice-ninja/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/invoice-ninja/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/invoice-ninja/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/invoice-ninja/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/ipfs/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/ipfs/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/ipfs/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/ipfs/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/ipfs/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/ipfs/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/ipfs/templates/library/base_v2_1_9/container.py rename ix-dev/community/ipfs/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/ipfs/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/ipfs/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/ipfs/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/ipfs/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/ipfs/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/ipfs/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/ipfs/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/ipfs/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/ipfs/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/ipfs/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/ipfs/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/ipfs/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/ipfs/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/ipfs/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/ipfs/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/ipfs/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/ipfs/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/ipfs/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/ipfs/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/ipfs/templates/library/base_v2_1_9/ports.py rename ix-dev/community/ipfs/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/ipfs/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/ipfs/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/ipfs/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/ipfs/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/ipfs/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/ipfs/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/ipfs/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/ipfs/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/ipfs/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/ipfs/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/ipfs/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/ipfs/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/ipfs/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/ipfs/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/ipfs/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/ipfs/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/ipfs/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/ipfs/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/ipfs/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/ipfs/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/ipfs/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/ipfs/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/ipfs/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/ipfs/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/ipfs/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/ipfs/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/ipfs/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/ipfs/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/ipfs/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/ipfs/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/ipfs/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/ipfs/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/ipfs/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/jellyfin/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/jellyfin/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/jellyfin/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/jellyfin/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/jellyfin/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/jellyfin/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/jellyfin/templates/library/base_v2_1_9/container.py rename ix-dev/community/jellyfin/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/jellyfin/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/jellyfin/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/jellyfin/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/jellyfin/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/jellyfin/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/jellyfin/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/jellyfin/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/jellyfin/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/jellyfin/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/jellyfin/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/jellyfin/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/jellyfin/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/jellyfin/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/jellyfin/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/jellyfin/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/jellyfin/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/jellyfin/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/jellyfin/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/jellyfin/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/jellyfin/templates/library/base_v2_1_9/ports.py rename ix-dev/community/jellyfin/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/jellyfin/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/jellyfin/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/jellyfin/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/jellyfin/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/jellyfin/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/jellyfin/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/jellyfin/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/jellyfin/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/jellyfin/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/jellyfin/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/jellyfin/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/jellyfin/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/jellyfin/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/jellyfin/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/jellyfin/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/jellyfin/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/jellyfin/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/jellyfin/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/jellyfin/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/jellyfin/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/jellyfin/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/jellyfin/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/jellyfin/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/jellyfin/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/jellyfin/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/jellyfin/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/jellyfin/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/jellyfin/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/jellyfin/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/jellyfin/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/jellyfin/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/jellyfin/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/jellyfin/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/jellyseerr/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/jellyseerr/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/jellyseerr/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/jellyseerr/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/jellyseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/jellyseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/jellyseerr/templates/library/base_v2_1_9/container.py rename ix-dev/community/jellyseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/jellyseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/jellyseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/jellyseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/jellyseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/jellyseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/jellyseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/jellyseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/jellyseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/jellyseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/jellyseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/jellyseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/jellyseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/jellyseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/jellyseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/jellyseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/jellyseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/jellyseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/jellyseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/jellyseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/jellyseerr/templates/library/base_v2_1_9/ports.py rename ix-dev/community/jellyseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/jellyseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/jellyseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/jellyseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/jellyseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/jellyseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/jellyseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/jellyseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/jellyseerr/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/jellyseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/jellyseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/jellyseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/jellyseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/jellyseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/jellyseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/jellyseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/jellyseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/jellyseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/jellyseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/jellyseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/jellyseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/jellyseerr/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/jellyseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/jellyseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/jellyseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/jellyseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/jellyseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/jellyseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/jellyseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/jellyseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/jellyseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/jellyseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/jellyseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/jellyseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/jelu/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/jelu/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/jelu/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/jelu/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/jelu/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/jelu/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/jelu/templates/library/base_v2_1_9/container.py rename ix-dev/community/jelu/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/jelu/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/jelu/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/jelu/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/jelu/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/jelu/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/jelu/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/jelu/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/jelu/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/jelu/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/jelu/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/jelu/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/jelu/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/jelu/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/jelu/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/jelu/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/jelu/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/jelu/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/jelu/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/jelu/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/jelu/templates/library/base_v2_1_9/ports.py rename ix-dev/community/jelu/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/jelu/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/jelu/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/jelu/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/jelu/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/jelu/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/jelu/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/jelu/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/jelu/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/jelu/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/jelu/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/jelu/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/jelu/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/jelu/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/jelu/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/jelu/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/jelu/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/jelu/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/jelu/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/jelu/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/jelu/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/jelu/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/jelu/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/jelu/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/jelu/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/jelu/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/jelu/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/jelu/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/jelu/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/jelu/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/jelu/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/jelu/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/jelu/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/jelu/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/jenkins/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/jenkins/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/jenkins/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/jenkins/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/jenkins/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/jenkins/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/jenkins/templates/library/base_v2_1_9/container.py rename ix-dev/community/jenkins/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/jenkins/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/jenkins/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/jenkins/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/jenkins/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/jenkins/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/jenkins/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/jenkins/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/jenkins/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/jenkins/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/jenkins/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/jenkins/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/jenkins/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/jenkins/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/jenkins/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/jenkins/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/jenkins/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/jenkins/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/jenkins/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/jenkins/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/jenkins/templates/library/base_v2_1_9/ports.py rename ix-dev/community/jenkins/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/jenkins/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/jenkins/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/jenkins/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/jenkins/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/jenkins/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/jenkins/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/jenkins/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/jenkins/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/jenkins/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/jenkins/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/jenkins/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/jenkins/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/jenkins/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/jenkins/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/jenkins/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/jenkins/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/jenkins/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/jenkins/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/jenkins/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/jenkins/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/jenkins/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/jenkins/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/jenkins/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/jenkins/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/jenkins/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/jenkins/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/jenkins/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/jenkins/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/jenkins/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/jenkins/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/jenkins/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/jenkins/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/jenkins/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/joplin/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/joplin/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/joplin/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/joplin/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/joplin/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/joplin/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/joplin/templates/library/base_v2_1_9/container.py rename ix-dev/community/joplin/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/joplin/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/joplin/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/joplin/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/joplin/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/joplin/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/joplin/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/joplin/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/joplin/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/joplin/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/joplin/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/joplin/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/joplin/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/joplin/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/joplin/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/joplin/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/joplin/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/joplin/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/joplin/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/joplin/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/joplin/templates/library/base_v2_1_9/ports.py rename ix-dev/community/joplin/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/joplin/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/joplin/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/joplin/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/joplin/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/joplin/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/joplin/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/joplin/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/joplin/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/joplin/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/joplin/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/joplin/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/joplin/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/joplin/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/joplin/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/joplin/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/joplin/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/joplin/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/joplin/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/joplin/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/joplin/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/joplin/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/joplin/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/joplin/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/joplin/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/joplin/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/joplin/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/joplin/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/joplin/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/joplin/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/joplin/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/joplin/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/joplin/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/joplin/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/kapowarr/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/kapowarr/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/kapowarr/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/kapowarr/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/kapowarr/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/kapowarr/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/kapowarr/templates/library/base_v2_1_9/container.py rename ix-dev/community/kapowarr/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/kapowarr/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/kapowarr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/kapowarr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/kapowarr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/kapowarr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/kapowarr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/kapowarr/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/kapowarr/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/kapowarr/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/kapowarr/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/kapowarr/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/kapowarr/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/kapowarr/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/kapowarr/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/kapowarr/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/kapowarr/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/kapowarr/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/kapowarr/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/kapowarr/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/kapowarr/templates/library/base_v2_1_9/ports.py rename ix-dev/community/kapowarr/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/kapowarr/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/kapowarr/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/kapowarr/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/kapowarr/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/kapowarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/kapowarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/kapowarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/kapowarr/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/kapowarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/kapowarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/kapowarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/kapowarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/kapowarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/kapowarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/kapowarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/kapowarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/kapowarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/kapowarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/kapowarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/kapowarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/kapowarr/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/kapowarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/kapowarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/kapowarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/kapowarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/kapowarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/kapowarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/kapowarr/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/kapowarr/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/kapowarr/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/kapowarr/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/kapowarr/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/kapowarr/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/kavita/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/kavita/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/kavita/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/kavita/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/kavita/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/kavita/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/kavita/templates/library/base_v2_1_9/container.py rename ix-dev/community/kavita/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/kavita/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/kavita/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/kavita/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/kavita/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/kavita/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/kavita/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/kavita/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/kavita/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/kavita/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/kavita/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/kavita/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/kavita/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/kavita/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/kavita/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/kavita/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/kavita/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/kavita/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/kavita/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/kavita/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/kavita/templates/library/base_v2_1_9/ports.py rename ix-dev/community/kavita/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/kavita/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/kavita/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/kavita/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/kavita/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/kavita/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/kavita/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/kavita/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/kavita/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/kavita/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/kavita/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/kavita/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/kavita/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/kavita/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/kavita/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/kavita/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/kavita/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/kavita/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/kavita/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/kavita/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/kavita/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/kavita/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/kavita/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/kavita/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/kavita/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/kavita/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/kavita/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/kavita/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/kavita/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/kavita/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/kavita/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/kavita/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/kavita/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/kavita/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/komga/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/komga/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/komga/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/komga/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/komga/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/komga/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/komga/templates/library/base_v2_1_9/container.py rename ix-dev/community/komga/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/komga/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/komga/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/komga/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/komga/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/komga/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/komga/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/komga/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/komga/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/komga/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/komga/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/komga/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/komga/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/komga/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/komga/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/komga/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/komga/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/komga/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/komga/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/komga/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/komga/templates/library/base_v2_1_9/ports.py rename ix-dev/community/komga/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/komga/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/komga/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/komga/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/komga/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/komga/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/komga/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/komga/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/komga/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/komga/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/komga/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/komga/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/komga/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/komga/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/komga/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/komga/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/komga/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/komga/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/komga/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/komga/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/komga/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/komga/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/komga/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/komga/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/komga/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/komga/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/komga/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/komga/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/komga/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/komga/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/komga/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/komga/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/komga/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/komga/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/lidarr/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/lidarr/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/lidarr/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/lidarr/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/lidarr/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/lidarr/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/lidarr/templates/library/base_v2_1_9/container.py rename ix-dev/community/lidarr/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/lidarr/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/lidarr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/lidarr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/lidarr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/lidarr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/lidarr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/lidarr/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/lidarr/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/lidarr/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/lidarr/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/lidarr/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/lidarr/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/lidarr/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/lidarr/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/lidarr/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/lidarr/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/lidarr/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/lidarr/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/lidarr/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/lidarr/templates/library/base_v2_1_9/ports.py rename ix-dev/community/lidarr/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/lidarr/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/lidarr/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/lidarr/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/lidarr/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/lidarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/lidarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/lidarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/lidarr/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/lidarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/lidarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/lidarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/lidarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/lidarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/lidarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/lidarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/lidarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/lidarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/lidarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/lidarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/lidarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/lidarr/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/lidarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/lidarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/lidarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/lidarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/lidarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/lidarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/lidarr/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/lidarr/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/lidarr/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/lidarr/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/lidarr/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/lidarr/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/linkding/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/linkding/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/linkding/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/linkding/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/linkding/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/linkding/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/linkding/templates/library/base_v2_1_9/container.py rename ix-dev/community/linkding/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/linkding/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/linkding/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/linkding/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/linkding/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/linkding/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/linkding/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/linkding/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/linkding/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/linkding/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/linkding/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/linkding/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/linkding/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/linkding/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/linkding/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/linkding/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/linkding/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/linkding/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/linkding/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/linkding/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/linkding/templates/library/base_v2_1_9/ports.py rename ix-dev/community/linkding/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/linkding/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/linkding/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/linkding/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/linkding/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/linkding/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/linkding/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/linkding/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/linkding/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/linkding/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/linkding/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/linkding/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/linkding/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/linkding/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/linkding/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/linkding/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/linkding/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/linkding/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/linkding/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/linkding/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/linkding/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/linkding/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/linkding/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/linkding/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/linkding/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/linkding/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/linkding/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/linkding/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/linkding/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/linkding/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/linkding/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/linkding/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/linkding/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/linkding/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/listmonk/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/listmonk/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/listmonk/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/listmonk/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/listmonk/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/listmonk/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/listmonk/templates/library/base_v2_1_9/container.py rename ix-dev/community/listmonk/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/listmonk/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/listmonk/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/listmonk/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/listmonk/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/listmonk/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/listmonk/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/listmonk/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/listmonk/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/listmonk/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/listmonk/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/listmonk/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/listmonk/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/listmonk/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/listmonk/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/listmonk/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/listmonk/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/listmonk/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/listmonk/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/listmonk/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/listmonk/templates/library/base_v2_1_9/ports.py rename ix-dev/community/listmonk/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/listmonk/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/listmonk/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/listmonk/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/listmonk/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/listmonk/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/listmonk/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/listmonk/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/listmonk/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/listmonk/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/listmonk/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/listmonk/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/listmonk/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/listmonk/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/listmonk/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/listmonk/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/listmonk/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/listmonk/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/listmonk/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/listmonk/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/listmonk/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/listmonk/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/listmonk/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/listmonk/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/listmonk/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/listmonk/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/listmonk/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/listmonk/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/listmonk/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/listmonk/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/listmonk/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/listmonk/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/listmonk/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/listmonk/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/logseq/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/logseq/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/logseq/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/logseq/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/logseq/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/logseq/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/logseq/templates/library/base_v2_1_9/container.py rename ix-dev/community/logseq/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/logseq/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/logseq/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/logseq/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/logseq/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/logseq/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/logseq/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/logseq/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/logseq/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/logseq/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/logseq/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/logseq/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/logseq/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/logseq/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/logseq/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/logseq/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/logseq/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/logseq/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/logseq/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/logseq/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/logseq/templates/library/base_v2_1_9/ports.py rename ix-dev/community/logseq/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/logseq/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/logseq/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/logseq/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/logseq/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/logseq/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/logseq/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/logseq/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/logseq/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/logseq/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/logseq/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/logseq/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/logseq/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/logseq/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/logseq/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/logseq/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/logseq/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/logseq/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/logseq/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/logseq/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/logseq/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/logseq/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/logseq/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/logseq/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/logseq/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/logseq/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/logseq/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/logseq/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/logseq/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/logseq/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/logseq/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/logseq/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/logseq/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/logseq/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/lyrion-music-server/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/lyrion-music-server/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/container.py rename ix-dev/community/lyrion-music-server/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/lyrion-music-server/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/lyrion-music-server/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/lyrion-music-server/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/lyrion-music-server/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/lyrion-music-server/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/lyrion-music-server/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/lyrion-music-server/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/lyrion-music-server/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/lyrion-music-server/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/lyrion-music-server/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/lyrion-music-server/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/lyrion-music-server/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/lyrion-music-server/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/lyrion-music-server/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/lyrion-music-server/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/lyrion-music-server/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/lyrion-music-server/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/lyrion-music-server/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/lyrion-music-server/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/ports.py rename ix-dev/community/lyrion-music-server/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/lyrion-music-server/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/lyrion-music-server/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/lyrion-music-server/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/lyrion-music-server/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/lyrion-music-server/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/lyrion-music-server/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/lyrion-music-server/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/lyrion-music-server/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/lyrion-music-server/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/lyrion-music-server/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/lyrion-music-server/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/lyrion-music-server/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/lyrion-music-server/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/lyrion-music-server/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/lyrion-music-server/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/lyrion-music-server/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/lyrion-music-server/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/lyrion-music-server/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/lyrion-music-server/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/lyrion-music-server/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/lyrion-music-server/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/lyrion-music-server/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/lyrion-music-server/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/lyrion-music-server/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/lyrion-music-server/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/lyrion-music-server/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/lyrion-music-server/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/lyrion-music-server/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/lyrion-music-server/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/lyrion-music-server/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/lyrion-music-server/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/mealie/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/mealie/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/mealie/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/mealie/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/mealie/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/mealie/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/mealie/templates/library/base_v2_1_9/container.py rename ix-dev/community/mealie/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/mealie/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/mealie/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/mealie/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/mealie/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/mealie/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/mealie/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/mealie/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/mealie/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/mealie/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/mealie/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/mealie/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/mealie/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/mealie/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/mealie/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/mealie/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/mealie/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/mealie/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/mealie/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/mealie/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/mealie/templates/library/base_v2_1_9/ports.py rename ix-dev/community/mealie/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/mealie/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/mealie/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/mealie/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/mealie/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/mealie/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/mealie/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/mealie/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/mealie/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/mealie/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/mealie/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/mealie/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/mealie/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/mealie/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/mealie/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/mealie/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/mealie/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/mealie/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/mealie/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/mealie/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/mealie/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/mealie/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/mealie/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/mealie/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/mealie/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/mealie/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/mealie/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/mealie/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/mealie/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/mealie/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/mealie/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/mealie/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/mealie/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/mealie/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/metube/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/metube/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/metube/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/metube/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/metube/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/metube/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/metube/templates/library/base_v2_1_9/container.py rename ix-dev/community/metube/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/metube/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/metube/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/metube/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/metube/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/metube/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/metube/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/metube/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/metube/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/metube/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/metube/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/metube/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/metube/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/metube/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/metube/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/metube/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/metube/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/metube/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/metube/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/metube/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/metube/templates/library/base_v2_1_9/ports.py rename ix-dev/community/metube/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/metube/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/metube/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/metube/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/metube/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/metube/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/metube/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/metube/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/metube/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/metube/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/metube/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/metube/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/metube/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/metube/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/metube/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/metube/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/metube/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/metube/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/metube/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/metube/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/metube/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/metube/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/metube/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/metube/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/metube/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/metube/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/metube/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/metube/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/metube/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/metube/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/metube/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/metube/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/metube/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/metube/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/minecraft/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/minecraft/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/minecraft/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/minecraft/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/minecraft/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/minecraft/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/minecraft/templates/library/base_v2_1_9/container.py rename ix-dev/community/minecraft/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/minecraft/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/minecraft/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/minecraft/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/minecraft/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/minecraft/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/minecraft/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/minecraft/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/minecraft/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/minecraft/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/minecraft/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/minecraft/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/minecraft/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/minecraft/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/minecraft/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/minecraft/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/minecraft/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/minecraft/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/minecraft/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/minecraft/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/minecraft/templates/library/base_v2_1_9/ports.py rename ix-dev/community/minecraft/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/minecraft/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/minecraft/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/minecraft/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/minecraft/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/minecraft/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/minecraft/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/minecraft/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/minecraft/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/minecraft/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/minecraft/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/minecraft/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/minecraft/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/minecraft/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/minecraft/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/minecraft/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/minecraft/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/minecraft/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/minecraft/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/minecraft/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/minecraft/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/minecraft/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/minecraft/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/minecraft/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/minecraft/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/minecraft/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/minecraft/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/minecraft/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/minecraft/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/minecraft/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/minecraft/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/minecraft/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/minecraft/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/minecraft/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/mineos/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/mineos/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/mineos/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/mineos/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/mineos/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/mineos/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/mineos/templates/library/base_v2_1_9/container.py rename ix-dev/community/mineos/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/mineos/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/mineos/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/mineos/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/mineos/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/mineos/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/mineos/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/mineos/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/mineos/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/mineos/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/mineos/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/mineos/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/mineos/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/mineos/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/mineos/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/mineos/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/mineos/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/mineos/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/mineos/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/mineos/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/mineos/templates/library/base_v2_1_9/ports.py rename ix-dev/community/mineos/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/mineos/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/mineos/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/mineos/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/mineos/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/mineos/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/mineos/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/mineos/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/mineos/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/mineos/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/mineos/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/mineos/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/mineos/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/mineos/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/mineos/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/mineos/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/mineos/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/mineos/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/mineos/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/mineos/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/mineos/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/mineos/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/mineos/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/mineos/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/mineos/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/mineos/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/mineos/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/mineos/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/mineos/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/mineos/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/mineos/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/mineos/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/mineos/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/mineos/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/mumble/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/mumble/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/mumble/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/mumble/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/mumble/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/mumble/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/mumble/templates/library/base_v2_1_9/container.py rename ix-dev/community/mumble/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/mumble/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/mumble/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/mumble/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/mumble/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/mumble/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/mumble/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/mumble/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/mumble/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/mumble/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/mumble/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/mumble/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/mumble/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/mumble/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/mumble/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/mumble/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/mumble/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/mumble/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/mumble/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/mumble/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/mumble/templates/library/base_v2_1_9/ports.py rename ix-dev/community/mumble/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/mumble/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/mumble/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/mumble/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/mumble/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/mumble/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/mumble/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/mumble/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/mumble/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/mumble/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/mumble/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/mumble/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/mumble/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/mumble/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/mumble/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/mumble/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/mumble/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/mumble/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/mumble/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/mumble/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/mumble/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/mumble/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/mumble/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/mumble/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/mumble/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/mumble/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/mumble/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/mumble/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/mumble/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/mumble/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/mumble/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/mumble/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/mumble/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/mumble/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/n8n/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/n8n/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/n8n/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/n8n/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/n8n/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/n8n/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/n8n/templates/library/base_v2_1_9/container.py rename ix-dev/community/n8n/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/n8n/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/n8n/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/n8n/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/n8n/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/n8n/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/n8n/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/n8n/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/n8n/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/n8n/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/n8n/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/n8n/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/n8n/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/n8n/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/n8n/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/n8n/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/n8n/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/n8n/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/n8n/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/n8n/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/n8n/templates/library/base_v2_1_9/ports.py rename ix-dev/community/n8n/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/n8n/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/n8n/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/n8n/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/n8n/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/n8n/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/n8n/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/n8n/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/n8n/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/n8n/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/n8n/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/n8n/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/n8n/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/n8n/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/n8n/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/n8n/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/n8n/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/n8n/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/n8n/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/n8n/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/n8n/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/n8n/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/n8n/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/n8n/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/n8n/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/n8n/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/n8n/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/n8n/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/n8n/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/n8n/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/n8n/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/n8n/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/n8n/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/n8n/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/navidrome/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/navidrome/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/navidrome/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/navidrome/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/navidrome/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/navidrome/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/navidrome/templates/library/base_v2_1_9/container.py rename ix-dev/community/navidrome/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/navidrome/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/navidrome/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/navidrome/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/navidrome/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/navidrome/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/navidrome/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/navidrome/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/navidrome/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/navidrome/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/navidrome/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/navidrome/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/navidrome/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/navidrome/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/navidrome/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/navidrome/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/navidrome/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/navidrome/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/navidrome/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/navidrome/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/navidrome/templates/library/base_v2_1_9/ports.py rename ix-dev/community/navidrome/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/navidrome/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/navidrome/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/navidrome/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/navidrome/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/navidrome/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/navidrome/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/navidrome/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/navidrome/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/navidrome/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/navidrome/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/navidrome/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/navidrome/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/navidrome/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/navidrome/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/navidrome/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/navidrome/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/navidrome/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/navidrome/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/navidrome/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/navidrome/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/navidrome/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/navidrome/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/navidrome/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/navidrome/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/navidrome/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/navidrome/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/navidrome/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/navidrome/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/navidrome/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/navidrome/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/navidrome/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/navidrome/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/navidrome/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/netbootxyz/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/netbootxyz/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/netbootxyz/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/netbootxyz/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/netbootxyz/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/netbootxyz/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/netbootxyz/templates/library/base_v2_1_9/container.py rename ix-dev/community/netbootxyz/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/netbootxyz/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/netbootxyz/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/netbootxyz/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/netbootxyz/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/netbootxyz/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/netbootxyz/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/netbootxyz/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/netbootxyz/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/netbootxyz/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/netbootxyz/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/netbootxyz/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/netbootxyz/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/netbootxyz/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/netbootxyz/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/netbootxyz/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/netbootxyz/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/netbootxyz/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/netbootxyz/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/netbootxyz/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/netbootxyz/templates/library/base_v2_1_9/ports.py rename ix-dev/community/netbootxyz/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/netbootxyz/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/netbootxyz/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/netbootxyz/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/netbootxyz/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/netbootxyz/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/netbootxyz/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/netbootxyz/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/netbootxyz/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/netbootxyz/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/netbootxyz/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/netbootxyz/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/netbootxyz/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/netbootxyz/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/netbootxyz/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/netbootxyz/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/netbootxyz/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/netbootxyz/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/netbootxyz/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/netbootxyz/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/netbootxyz/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/netbootxyz/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/netbootxyz/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/netbootxyz/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/netbootxyz/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/netbootxyz/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/netbootxyz/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/netbootxyz/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/netbootxyz/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/netbootxyz/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/netbootxyz/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/netbootxyz/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/netbootxyz/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/netbootxyz/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/nginx-proxy-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/nginx-proxy-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/container.py rename ix-dev/community/nginx-proxy-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/nginx-proxy-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/nginx-proxy-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/nginx-proxy-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/nginx-proxy-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/nginx-proxy-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/nginx-proxy-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/nginx-proxy-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/nginx-proxy-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/nginx-proxy-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/nginx-proxy-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/nginx-proxy-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/nginx-proxy-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/nginx-proxy-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/nginx-proxy-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/nginx-proxy-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/nginx-proxy-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/nginx-proxy-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/nginx-proxy-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/nginx-proxy-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/ports.py rename ix-dev/community/nginx-proxy-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/nginx-proxy-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/nginx-proxy-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/nginx-proxy-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/nginx-proxy-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/nginx-proxy-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/nginx-proxy-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/nginx-proxy-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/nginx-proxy-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/nginx-proxy-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/nginx-proxy-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/nginx-proxy-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/nginx-proxy-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/nginx-proxy-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/nginx-proxy-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/nginx-proxy-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/nginx-proxy-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/nginx-proxy-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/nginx-proxy-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/nginx-proxy-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/nginx-proxy-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/nginx-proxy-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/nginx-proxy-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/nginx-proxy-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/nginx-proxy-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/nginx-proxy-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/nginx-proxy-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/nginx-proxy-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/nginx-proxy-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/nginx-proxy-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/nginx-proxy-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/nginx-proxy-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/node-red/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/node-red/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/node-red/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/node-red/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/node-red/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/node-red/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/node-red/templates/library/base_v2_1_9/container.py rename ix-dev/community/node-red/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/node-red/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/node-red/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/node-red/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/node-red/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/node-red/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/node-red/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/node-red/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/node-red/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/node-red/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/node-red/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/node-red/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/node-red/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/node-red/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/node-red/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/node-red/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/node-red/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/node-red/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/node-red/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/node-red/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/node-red/templates/library/base_v2_1_9/ports.py rename ix-dev/community/node-red/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/node-red/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/node-red/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/node-red/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/node-red/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/node-red/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/node-red/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/node-red/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/node-red/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/node-red/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/node-red/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/node-red/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/node-red/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/node-red/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/node-red/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/node-red/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/node-red/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/node-red/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/node-red/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/node-red/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/node-red/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/node-red/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/node-red/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/node-red/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/node-red/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/node-red/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/node-red/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/node-red/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/node-red/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/node-red/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/node-red/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/node-red/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/node-red/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/node-red/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/odoo/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/odoo/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/odoo/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/odoo/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/odoo/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/odoo/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/odoo/templates/library/base_v2_1_9/container.py rename ix-dev/community/odoo/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/odoo/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/odoo/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/odoo/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/odoo/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/odoo/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/odoo/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/odoo/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/odoo/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/odoo/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/odoo/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/odoo/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/odoo/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/odoo/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/odoo/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/odoo/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/odoo/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/odoo/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/odoo/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/odoo/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/odoo/templates/library/base_v2_1_9/ports.py rename ix-dev/community/odoo/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/odoo/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/odoo/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/odoo/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/odoo/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/odoo/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/odoo/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/odoo/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/odoo/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/odoo/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/odoo/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/odoo/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/odoo/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/odoo/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/odoo/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/odoo/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/odoo/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/odoo/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/odoo/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/odoo/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/odoo/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/odoo/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/odoo/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/odoo/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/odoo/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/odoo/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/odoo/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/odoo/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/odoo/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/odoo/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/odoo/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/odoo/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/odoo/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/odoo/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/ollama/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/ollama/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/ollama/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/ollama/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/ollama/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/ollama/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/ollama/templates/library/base_v2_1_9/container.py rename ix-dev/community/ollama/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/ollama/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/ollama/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/ollama/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/ollama/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/ollama/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/ollama/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/ollama/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/ollama/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/ollama/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/ollama/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/ollama/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/ollama/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/ollama/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/ollama/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/ollama/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/ollama/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/ollama/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/ollama/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/ollama/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/ollama/templates/library/base_v2_1_9/ports.py rename ix-dev/community/ollama/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/ollama/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/ollama/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/ollama/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/ollama/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/ollama/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/ollama/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/ollama/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/ollama/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/ollama/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/ollama/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/ollama/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/ollama/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/ollama/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/ollama/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/ollama/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/ollama/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/ollama/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/ollama/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/ollama/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/ollama/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/ollama/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/ollama/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/ollama/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/ollama/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/ollama/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/ollama/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/ollama/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/ollama/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/ollama/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/ollama/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/ollama/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/ollama/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/ollama/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/omada-controller/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/omada-controller/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/omada-controller/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/omada-controller/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/omada-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/omada-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/omada-controller/templates/library/base_v2_1_9/container.py rename ix-dev/community/omada-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/omada-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/omada-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/omada-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/omada-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/omada-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/omada-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/omada-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/omada-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/omada-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/omada-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/omada-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/omada-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/omada-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/omada-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/omada-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/omada-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/omada-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/omada-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/omada-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/omada-controller/templates/library/base_v2_1_9/ports.py rename ix-dev/community/omada-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/omada-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/omada-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/omada-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/omada-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/omada-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/omada-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/omada-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/omada-controller/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/omada-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/omada-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/omada-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/omada-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/omada-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/omada-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/omada-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/omada-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/omada-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/omada-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/omada-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/omada-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/omada-controller/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/omada-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/omada-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/omada-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/omada-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/omada-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/omada-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/omada-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/omada-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/omada-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/omada-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/omada-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/omada-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/open-webui/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/open-webui/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/open-webui/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/open-webui/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/open-webui/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/open-webui/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/open-webui/templates/library/base_v2_1_9/container.py rename ix-dev/community/open-webui/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/open-webui/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/open-webui/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/open-webui/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/open-webui/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/open-webui/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/open-webui/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/open-webui/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/open-webui/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/open-webui/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/open-webui/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/open-webui/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/open-webui/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/open-webui/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/open-webui/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/open-webui/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/open-webui/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/open-webui/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/open-webui/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/open-webui/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/open-webui/templates/library/base_v2_1_9/ports.py rename ix-dev/community/open-webui/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/open-webui/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/open-webui/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/open-webui/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/open-webui/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/open-webui/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/open-webui/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/open-webui/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/open-webui/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/open-webui/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/open-webui/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/open-webui/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/open-webui/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/open-webui/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/open-webui/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/open-webui/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/open-webui/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/open-webui/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/open-webui/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/open-webui/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/open-webui/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/open-webui/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/open-webui/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/open-webui/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/open-webui/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/open-webui/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/open-webui/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/open-webui/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/open-webui/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/open-webui/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/open-webui/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/open-webui/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/open-webui/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/open-webui/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/organizr/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/organizr/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/organizr/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/organizr/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/organizr/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/organizr/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/organizr/templates/library/base_v2_1_9/container.py rename ix-dev/community/organizr/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/organizr/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/organizr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/organizr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/organizr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/organizr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/organizr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/organizr/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/organizr/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/organizr/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/organizr/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/organizr/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/organizr/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/organizr/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/organizr/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/organizr/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/organizr/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/organizr/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/organizr/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/organizr/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/organizr/templates/library/base_v2_1_9/ports.py rename ix-dev/community/organizr/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/organizr/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/organizr/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/organizr/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/organizr/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/organizr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/organizr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/organizr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/organizr/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/organizr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/organizr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/organizr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/organizr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/organizr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/organizr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/organizr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/organizr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/organizr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/organizr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/organizr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/organizr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/organizr/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/organizr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/organizr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/organizr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/organizr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/organizr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/organizr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/organizr/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/organizr/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/organizr/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/organizr/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/organizr/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/organizr/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/overseerr/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/overseerr/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/overseerr/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/overseerr/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/overseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/overseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/overseerr/templates/library/base_v2_1_9/container.py rename ix-dev/community/overseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/overseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/overseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/overseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/overseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/overseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/overseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/overseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/overseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/overseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/overseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/overseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/overseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/overseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/overseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/overseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/overseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/overseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/overseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/overseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/overseerr/templates/library/base_v2_1_9/ports.py rename ix-dev/community/overseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/overseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/overseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/overseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/overseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/overseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/overseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/overseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/overseerr/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/overseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/overseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/overseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/overseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/overseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/overseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/overseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/overseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/overseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/overseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/overseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/overseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/overseerr/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/overseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/overseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/overseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/overseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/overseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/overseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/overseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/overseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/overseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/overseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/overseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/overseerr/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/palworld/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/palworld/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/palworld/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/palworld/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/palworld/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/palworld/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/palworld/templates/library/base_v2_1_9/container.py rename ix-dev/community/palworld/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/palworld/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/palworld/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/palworld/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/palworld/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/palworld/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/palworld/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/palworld/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/palworld/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/palworld/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/palworld/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/palworld/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/palworld/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/palworld/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/palworld/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/palworld/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/palworld/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/palworld/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/palworld/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/palworld/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/palworld/templates/library/base_v2_1_9/ports.py rename ix-dev/community/palworld/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/palworld/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/palworld/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/palworld/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/palworld/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/palworld/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/palworld/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/palworld/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/palworld/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/palworld/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/palworld/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/palworld/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/palworld/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/palworld/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/palworld/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/palworld/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/palworld/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/palworld/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/palworld/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/palworld/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/palworld/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/palworld/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/palworld/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/palworld/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/palworld/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/palworld/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/palworld/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/palworld/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/palworld/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/palworld/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/palworld/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/palworld/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/palworld/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/palworld/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/paperless-ngx/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/paperless-ngx/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/container.py rename ix-dev/community/paperless-ngx/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/paperless-ngx/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/paperless-ngx/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/paperless-ngx/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/paperless-ngx/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/paperless-ngx/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/paperless-ngx/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/paperless-ngx/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/paperless-ngx/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/paperless-ngx/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/paperless-ngx/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/paperless-ngx/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/paperless-ngx/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/paperless-ngx/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/paperless-ngx/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/paperless-ngx/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/paperless-ngx/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/paperless-ngx/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/paperless-ngx/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/paperless-ngx/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/ports.py rename ix-dev/community/paperless-ngx/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/paperless-ngx/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/paperless-ngx/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/paperless-ngx/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/paperless-ngx/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/paperless-ngx/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/paperless-ngx/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/paperless-ngx/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/paperless-ngx/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/paperless-ngx/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/paperless-ngx/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/paperless-ngx/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/paperless-ngx/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/paperless-ngx/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/paperless-ngx/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/paperless-ngx/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/paperless-ngx/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/paperless-ngx/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/paperless-ngx/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/paperless-ngx/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/paperless-ngx/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/paperless-ngx/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/paperless-ngx/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/paperless-ngx/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/paperless-ngx/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/paperless-ngx/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/paperless-ngx/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/paperless-ngx/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/paperless-ngx/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/paperless-ngx/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/paperless-ngx/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/paperless-ngx/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/passbolt/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/passbolt/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/passbolt/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/passbolt/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/passbolt/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/passbolt/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/passbolt/templates/library/base_v2_1_9/container.py rename ix-dev/community/passbolt/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/passbolt/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/passbolt/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/passbolt/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/passbolt/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/passbolt/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/passbolt/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/passbolt/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/passbolt/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/passbolt/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/passbolt/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/passbolt/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/passbolt/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/passbolt/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/passbolt/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/passbolt/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/passbolt/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/passbolt/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/passbolt/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/passbolt/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/passbolt/templates/library/base_v2_1_9/ports.py rename ix-dev/community/passbolt/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/passbolt/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/passbolt/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/passbolt/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/passbolt/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/passbolt/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/passbolt/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/passbolt/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/passbolt/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/passbolt/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/passbolt/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/passbolt/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/passbolt/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/passbolt/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/passbolt/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/passbolt/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/passbolt/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/passbolt/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/passbolt/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/passbolt/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/passbolt/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/passbolt/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/passbolt/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/passbolt/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/passbolt/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/passbolt/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/passbolt/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/passbolt/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/passbolt/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/passbolt/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/passbolt/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/passbolt/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/passbolt/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/passbolt/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/penpot/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/penpot/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/penpot/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/penpot/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/penpot/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/penpot/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/penpot/templates/library/base_v2_1_9/container.py rename ix-dev/community/penpot/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/penpot/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/penpot/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/penpot/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/penpot/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/penpot/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/penpot/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/penpot/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/penpot/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/penpot/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/penpot/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/penpot/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/penpot/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/penpot/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/penpot/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/penpot/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/penpot/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/penpot/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/penpot/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/penpot/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/penpot/templates/library/base_v2_1_9/ports.py rename ix-dev/community/penpot/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/penpot/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/penpot/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/penpot/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/penpot/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/penpot/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/penpot/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/penpot/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/penpot/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/penpot/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/penpot/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/penpot/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/penpot/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/penpot/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/penpot/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/penpot/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/penpot/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/penpot/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/penpot/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/penpot/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/penpot/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/penpot/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/penpot/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/penpot/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/penpot/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/penpot/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/penpot/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/penpot/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/penpot/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/penpot/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/penpot/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/penpot/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/penpot/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/penpot/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/pgadmin/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/pgadmin/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/pgadmin/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/pgadmin/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/pgadmin/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/pgadmin/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/pgadmin/templates/library/base_v2_1_9/container.py rename ix-dev/community/pgadmin/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/pgadmin/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/pgadmin/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/pgadmin/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/pgadmin/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/pgadmin/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/pgadmin/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/pgadmin/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/pgadmin/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/pgadmin/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/pgadmin/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/pgadmin/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/pgadmin/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/pgadmin/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/pgadmin/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/pgadmin/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/pgadmin/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/pgadmin/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/pgadmin/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/pgadmin/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/pgadmin/templates/library/base_v2_1_9/ports.py rename ix-dev/community/pgadmin/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/pgadmin/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/pgadmin/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/pgadmin/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/pgadmin/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/pgadmin/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/pgadmin/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/pgadmin/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/pgadmin/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/pgadmin/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/pgadmin/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/pgadmin/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/pgadmin/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/pgadmin/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/pgadmin/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/pgadmin/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/pgadmin/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/pgadmin/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/pgadmin/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/pgadmin/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/pgadmin/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/pgadmin/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/pgadmin/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/pgadmin/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/pgadmin/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/pgadmin/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/pgadmin/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/pgadmin/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/pgadmin/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/pgadmin/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/pgadmin/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/pgadmin/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/pgadmin/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/pgadmin/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/pigallery2/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/pigallery2/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/pigallery2/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/pigallery2/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/pigallery2/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/pigallery2/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/pigallery2/templates/library/base_v2_1_9/container.py rename ix-dev/community/pigallery2/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/pigallery2/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/pigallery2/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/pigallery2/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/pigallery2/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/pigallery2/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/pigallery2/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/pigallery2/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/pigallery2/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/pigallery2/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/pigallery2/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/pigallery2/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/pigallery2/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/pigallery2/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/pigallery2/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/pigallery2/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/pigallery2/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/pigallery2/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/pigallery2/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/pigallery2/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/pigallery2/templates/library/base_v2_1_9/ports.py rename ix-dev/community/pigallery2/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/pigallery2/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/pigallery2/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/pigallery2/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/pigallery2/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/pigallery2/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/pigallery2/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/pigallery2/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/pigallery2/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/pigallery2/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/pigallery2/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/pigallery2/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/pigallery2/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/pigallery2/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/pigallery2/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/pigallery2/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/pigallery2/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/pigallery2/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/pigallery2/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/pigallery2/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/pigallery2/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/pigallery2/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/pigallery2/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/pigallery2/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/pigallery2/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/pigallery2/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/pigallery2/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/pigallery2/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/pigallery2/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/pigallery2/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/pigallery2/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/pigallery2/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/pigallery2/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/pigallery2/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/piwigo/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/piwigo/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/piwigo/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/piwigo/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/piwigo/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/piwigo/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/piwigo/templates/library/base_v2_1_9/container.py rename ix-dev/community/piwigo/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/piwigo/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/piwigo/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/piwigo/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/piwigo/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/piwigo/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/piwigo/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/piwigo/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/piwigo/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/piwigo/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/piwigo/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/piwigo/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/piwigo/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/piwigo/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/piwigo/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/piwigo/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/piwigo/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/piwigo/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/piwigo/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/piwigo/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/piwigo/templates/library/base_v2_1_9/ports.py rename ix-dev/community/piwigo/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/piwigo/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/piwigo/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/piwigo/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/piwigo/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/piwigo/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/piwigo/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/piwigo/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/piwigo/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/piwigo/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/piwigo/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/piwigo/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/piwigo/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/piwigo/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/piwigo/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/piwigo/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/piwigo/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/piwigo/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/piwigo/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/piwigo/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/piwigo/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/piwigo/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/piwigo/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/piwigo/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/piwigo/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/piwigo/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/piwigo/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/piwigo/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/piwigo/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/piwigo/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/piwigo/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/piwigo/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/piwigo/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/piwigo/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/planka/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/planka/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/planka/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/planka/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/planka/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/planka/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/planka/templates/library/base_v2_1_9/container.py rename ix-dev/community/planka/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/planka/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/planka/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/planka/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/planka/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/planka/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/planka/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/planka/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/planka/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/planka/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/planka/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/planka/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/planka/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/planka/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/planka/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/planka/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/planka/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/planka/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/planka/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/planka/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/planka/templates/library/base_v2_1_9/ports.py rename ix-dev/community/planka/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/planka/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/planka/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/planka/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/planka/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/planka/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/planka/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/planka/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/planka/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/planka/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/planka/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/planka/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/planka/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/planka/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/planka/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/planka/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/planka/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/planka/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/planka/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/planka/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/planka/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/planka/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/planka/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/planka/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/planka/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/planka/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/planka/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/planka/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/planka/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/planka/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/planka/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/planka/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/planka/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/planka/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/plex-auto-languages/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/plex-auto-languages/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/container.py rename ix-dev/community/plex-auto-languages/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/plex-auto-languages/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/plex-auto-languages/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/plex-auto-languages/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/plex-auto-languages/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/plex-auto-languages/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/plex-auto-languages/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/plex-auto-languages/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/plex-auto-languages/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/plex-auto-languages/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/plex-auto-languages/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/plex-auto-languages/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/plex-auto-languages/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/plex-auto-languages/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/plex-auto-languages/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/plex-auto-languages/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/plex-auto-languages/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/plex-auto-languages/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/plex-auto-languages/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/plex-auto-languages/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/ports.py rename ix-dev/community/plex-auto-languages/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/plex-auto-languages/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/plex-auto-languages/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/plex-auto-languages/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/plex-auto-languages/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/plex-auto-languages/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/plex-auto-languages/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/plex-auto-languages/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/plex-auto-languages/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/plex-auto-languages/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/plex-auto-languages/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/plex-auto-languages/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/plex-auto-languages/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/plex-auto-languages/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/plex-auto-languages/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/plex-auto-languages/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/plex-auto-languages/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/plex-auto-languages/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/plex-auto-languages/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/plex-auto-languages/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/plex-auto-languages/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/plex-auto-languages/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/plex-auto-languages/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/plex-auto-languages/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/plex-auto-languages/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/plex-auto-languages/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/plex-auto-languages/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/plex-auto-languages/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/plex-auto-languages/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/plex-auto-languages/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/plex-auto-languages/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/plex-auto-languages/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/portainer/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/portainer/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/portainer/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/portainer/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/portainer/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/portainer/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/portainer/templates/library/base_v2_1_9/container.py rename ix-dev/community/portainer/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/portainer/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/portainer/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/portainer/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/portainer/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/portainer/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/portainer/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/portainer/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/portainer/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/portainer/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/portainer/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/portainer/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/portainer/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/portainer/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/portainer/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/portainer/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/portainer/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/portainer/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/portainer/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/portainer/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/portainer/templates/library/base_v2_1_9/ports.py rename ix-dev/community/portainer/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/portainer/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/portainer/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/portainer/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/portainer/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/portainer/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/portainer/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/portainer/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/portainer/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/portainer/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/portainer/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/portainer/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/portainer/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/portainer/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/portainer/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/portainer/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/portainer/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/portainer/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/portainer/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/portainer/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/portainer/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/portainer/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/portainer/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/portainer/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/portainer/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/portainer/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/portainer/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/portainer/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/portainer/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/portainer/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/portainer/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/portainer/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/portainer/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/portainer/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/postgres/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/postgres/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/postgres/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/postgres/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/postgres/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/postgres/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/postgres/templates/library/base_v2_1_9/container.py rename ix-dev/community/postgres/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/postgres/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/postgres/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/postgres/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/postgres/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/postgres/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/postgres/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/postgres/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/postgres/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/postgres/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/postgres/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/postgres/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/postgres/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/postgres/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/postgres/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/postgres/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/postgres/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/postgres/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/postgres/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/postgres/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/postgres/templates/library/base_v2_1_9/ports.py rename ix-dev/community/postgres/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/postgres/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/postgres/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/postgres/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/postgres/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/postgres/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/postgres/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/postgres/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/postgres/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/postgres/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/postgres/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/postgres/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/postgres/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/postgres/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/postgres/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/postgres/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/postgres/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/postgres/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/postgres/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/postgres/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/postgres/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/postgres/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/postgres/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/postgres/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/postgres/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/postgres/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/postgres/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/postgres/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/postgres/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/postgres/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/postgres/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/postgres/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/postgres/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/postgres/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/prowlarr/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/prowlarr/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/prowlarr/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/prowlarr/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/prowlarr/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/prowlarr/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/prowlarr/templates/library/base_v2_1_9/container.py rename ix-dev/community/prowlarr/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/prowlarr/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/prowlarr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/prowlarr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/prowlarr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/prowlarr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/prowlarr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/prowlarr/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/prowlarr/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/prowlarr/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/prowlarr/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/prowlarr/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/prowlarr/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/prowlarr/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/prowlarr/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/prowlarr/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/prowlarr/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/prowlarr/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/prowlarr/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/prowlarr/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/prowlarr/templates/library/base_v2_1_9/ports.py rename ix-dev/community/prowlarr/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/prowlarr/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/prowlarr/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/prowlarr/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/prowlarr/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/prowlarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/prowlarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/prowlarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/prowlarr/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/prowlarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/prowlarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/prowlarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/prowlarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/prowlarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/prowlarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/prowlarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/prowlarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/prowlarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/prowlarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/prowlarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/prowlarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/prowlarr/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/prowlarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/prowlarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/prowlarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/prowlarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/prowlarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/prowlarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/prowlarr/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/prowlarr/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/prowlarr/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/prowlarr/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/prowlarr/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/prowlarr/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/qbittorrent/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/qbittorrent/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/qbittorrent/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/qbittorrent/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/qbittorrent/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/qbittorrent/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/qbittorrent/templates/library/base_v2_1_9/container.py rename ix-dev/community/qbittorrent/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/qbittorrent/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/qbittorrent/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/qbittorrent/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/qbittorrent/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/qbittorrent/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/qbittorrent/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/qbittorrent/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/qbittorrent/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/qbittorrent/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/qbittorrent/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/qbittorrent/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/qbittorrent/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/qbittorrent/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/qbittorrent/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/qbittorrent/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/qbittorrent/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/qbittorrent/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/qbittorrent/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/qbittorrent/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/qbittorrent/templates/library/base_v2_1_9/ports.py rename ix-dev/community/qbittorrent/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/qbittorrent/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/qbittorrent/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/qbittorrent/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/qbittorrent/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/qbittorrent/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/qbittorrent/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/qbittorrent/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/qbittorrent/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/qbittorrent/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/qbittorrent/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/qbittorrent/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/qbittorrent/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/qbittorrent/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/qbittorrent/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/qbittorrent/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/qbittorrent/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/qbittorrent/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/qbittorrent/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/qbittorrent/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/qbittorrent/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/qbittorrent/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/qbittorrent/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/qbittorrent/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/qbittorrent/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/qbittorrent/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/qbittorrent/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/qbittorrent/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/qbittorrent/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/qbittorrent/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/qbittorrent/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/qbittorrent/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/qbittorrent/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/qbittorrent/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/radarr/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/radarr/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/radarr/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/radarr/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/radarr/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/radarr/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/radarr/templates/library/base_v2_1_9/container.py rename ix-dev/community/radarr/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/radarr/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/radarr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/radarr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/radarr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/radarr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/radarr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/radarr/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/radarr/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/radarr/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/radarr/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/radarr/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/radarr/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/radarr/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/radarr/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/radarr/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/radarr/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/radarr/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/radarr/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/radarr/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/radarr/templates/library/base_v2_1_9/ports.py rename ix-dev/community/radarr/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/radarr/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/radarr/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/radarr/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/radarr/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/radarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/radarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/radarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/radarr/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/radarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/radarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/radarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/radarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/radarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/radarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/radarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/radarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/radarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/radarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/radarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/radarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/radarr/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/radarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/radarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/radarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/radarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/radarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/radarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/radarr/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/radarr/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/radarr/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/radarr/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/radarr/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/radarr/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/readarr/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/readarr/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/readarr/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/readarr/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/readarr/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/readarr/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/readarr/templates/library/base_v2_1_9/container.py rename ix-dev/community/readarr/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/readarr/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/readarr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/readarr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/readarr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/readarr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/readarr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/readarr/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/readarr/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/readarr/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/readarr/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/readarr/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/readarr/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/readarr/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/readarr/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/readarr/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/readarr/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/readarr/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/readarr/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/readarr/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/readarr/templates/library/base_v2_1_9/ports.py rename ix-dev/community/readarr/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/readarr/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/readarr/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/readarr/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/readarr/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/readarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/readarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/readarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/readarr/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/readarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/readarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/readarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/readarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/readarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/readarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/readarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/readarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/readarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/readarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/readarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/readarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/readarr/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/readarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/readarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/readarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/readarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/readarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/readarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/readarr/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/readarr/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/readarr/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/readarr/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/readarr/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/readarr/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/recyclarr/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/recyclarr/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/recyclarr/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/recyclarr/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/recyclarr/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/recyclarr/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/recyclarr/templates/library/base_v2_1_9/container.py rename ix-dev/community/recyclarr/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/recyclarr/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/recyclarr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/recyclarr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/recyclarr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/recyclarr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/recyclarr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/recyclarr/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/recyclarr/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/recyclarr/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/recyclarr/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/recyclarr/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/recyclarr/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/recyclarr/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/recyclarr/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/recyclarr/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/recyclarr/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/recyclarr/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/recyclarr/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/recyclarr/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/recyclarr/templates/library/base_v2_1_9/ports.py rename ix-dev/community/recyclarr/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/recyclarr/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/recyclarr/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/recyclarr/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/recyclarr/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/recyclarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/recyclarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/recyclarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/recyclarr/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/recyclarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/recyclarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/recyclarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/recyclarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/recyclarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/recyclarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/recyclarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/recyclarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/recyclarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/recyclarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/recyclarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/recyclarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/recyclarr/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/recyclarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/recyclarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/recyclarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/recyclarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/recyclarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/recyclarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/recyclarr/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/recyclarr/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/recyclarr/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/recyclarr/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/recyclarr/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/recyclarr/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/redis/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/redis/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/redis/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/redis/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/redis/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/redis/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/redis/templates/library/base_v2_1_9/container.py rename ix-dev/community/redis/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/redis/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/redis/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/redis/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/redis/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/redis/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/redis/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/redis/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/redis/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/redis/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/redis/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/redis/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/redis/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/redis/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/redis/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/redis/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/redis/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/redis/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/redis/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/redis/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/redis/templates/library/base_v2_1_9/ports.py rename ix-dev/community/redis/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/redis/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/redis/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/redis/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/redis/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/redis/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/redis/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/redis/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/redis/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/redis/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/redis/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/redis/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/redis/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/redis/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/redis/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/redis/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/redis/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/redis/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/redis/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/redis/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/redis/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/redis/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/redis/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/redis/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/redis/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/redis/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/redis/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/redis/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/redis/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/redis/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/redis/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/redis/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/redis/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/redis/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/roundcube/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/roundcube/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/roundcube/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/roundcube/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/roundcube/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/roundcube/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/roundcube/templates/library/base_v2_1_9/container.py rename ix-dev/community/roundcube/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/roundcube/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/roundcube/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/roundcube/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/roundcube/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/roundcube/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/roundcube/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/roundcube/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/roundcube/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/roundcube/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/roundcube/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/roundcube/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/roundcube/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/roundcube/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/roundcube/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/roundcube/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/roundcube/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/roundcube/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/roundcube/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/roundcube/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/roundcube/templates/library/base_v2_1_9/ports.py rename ix-dev/community/roundcube/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/roundcube/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/roundcube/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/roundcube/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/roundcube/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/roundcube/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/roundcube/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/roundcube/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/roundcube/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/roundcube/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/roundcube/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/roundcube/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/roundcube/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/roundcube/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/roundcube/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/roundcube/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/roundcube/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/roundcube/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/roundcube/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/roundcube/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/roundcube/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/roundcube/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/roundcube/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/roundcube/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/roundcube/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/roundcube/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/roundcube/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/roundcube/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/roundcube/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/roundcube/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/roundcube/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/roundcube/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/roundcube/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/roundcube/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/rsyncd/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/rsyncd/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/rsyncd/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/rsyncd/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/rsyncd/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/rsyncd/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/rsyncd/templates/library/base_v2_1_9/container.py rename ix-dev/community/rsyncd/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/rsyncd/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/rsyncd/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/rsyncd/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/rsyncd/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/rsyncd/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/rsyncd/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/rsyncd/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/rsyncd/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/rsyncd/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/rsyncd/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/rsyncd/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/rsyncd/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/rsyncd/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/rsyncd/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/rsyncd/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/rsyncd/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/rsyncd/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/rsyncd/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/rsyncd/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/rsyncd/templates/library/base_v2_1_9/ports.py rename ix-dev/community/rsyncd/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/rsyncd/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/rsyncd/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/rsyncd/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/rsyncd/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/rsyncd/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/rsyncd/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/rsyncd/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/rsyncd/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/rsyncd/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/rsyncd/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/rsyncd/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/rsyncd/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/rsyncd/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/rsyncd/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/rsyncd/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/rsyncd/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/rsyncd/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/rsyncd/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/rsyncd/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/rsyncd/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/rsyncd/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/rsyncd/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/rsyncd/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/rsyncd/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/rsyncd/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/rsyncd/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/rsyncd/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/rsyncd/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/rsyncd/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/rsyncd/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/rsyncd/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/rsyncd/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/rsyncd/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/rust-desk/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/rust-desk/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/rust-desk/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/rust-desk/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/rust-desk/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/rust-desk/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/rust-desk/templates/library/base_v2_1_9/container.py rename ix-dev/community/rust-desk/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/rust-desk/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/rust-desk/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/rust-desk/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/rust-desk/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/rust-desk/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/rust-desk/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/rust-desk/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/rust-desk/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/rust-desk/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/rust-desk/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/rust-desk/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/rust-desk/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/rust-desk/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/rust-desk/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/rust-desk/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/rust-desk/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/rust-desk/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/rust-desk/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/rust-desk/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/rust-desk/templates/library/base_v2_1_9/ports.py rename ix-dev/community/rust-desk/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/rust-desk/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/rust-desk/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/rust-desk/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/rust-desk/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/rust-desk/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/rust-desk/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/rust-desk/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/rust-desk/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/rust-desk/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/rust-desk/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/rust-desk/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/rust-desk/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/rust-desk/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/rust-desk/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/rust-desk/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/rust-desk/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/rust-desk/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/rust-desk/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/rust-desk/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/rust-desk/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/rust-desk/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/rust-desk/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/rust-desk/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/rust-desk/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/rust-desk/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/rust-desk/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/rust-desk/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/rust-desk/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/rust-desk/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/rust-desk/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/rust-desk/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/rust-desk/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/rust-desk/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/sabnzbd/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/sabnzbd/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/sabnzbd/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/sabnzbd/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/sabnzbd/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/sabnzbd/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/sabnzbd/templates/library/base_v2_1_9/container.py rename ix-dev/community/sabnzbd/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/sabnzbd/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/sabnzbd/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/sabnzbd/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/sabnzbd/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/sabnzbd/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/sabnzbd/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/sabnzbd/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/sabnzbd/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/sabnzbd/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/sabnzbd/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/sabnzbd/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/sabnzbd/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/sabnzbd/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/sabnzbd/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/sabnzbd/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/sabnzbd/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/sabnzbd/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/sabnzbd/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/sabnzbd/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/sabnzbd/templates/library/base_v2_1_9/ports.py rename ix-dev/community/sabnzbd/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/sabnzbd/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/sabnzbd/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/sabnzbd/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/sabnzbd/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/sabnzbd/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/sabnzbd/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/sabnzbd/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/sabnzbd/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/sabnzbd/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/sabnzbd/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/sabnzbd/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/sabnzbd/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/sabnzbd/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/sabnzbd/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/sabnzbd/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/sabnzbd/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/sabnzbd/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/sabnzbd/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/sabnzbd/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/sabnzbd/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/sabnzbd/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/sabnzbd/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/sabnzbd/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/sabnzbd/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/sabnzbd/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/sabnzbd/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/sabnzbd/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/sabnzbd/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/sabnzbd/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/sabnzbd/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/sabnzbd/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/sabnzbd/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/sabnzbd/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/satisfactory-server/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/satisfactory-server/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/container.py rename ix-dev/community/satisfactory-server/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/satisfactory-server/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/satisfactory-server/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/satisfactory-server/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/satisfactory-server/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/satisfactory-server/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/satisfactory-server/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/satisfactory-server/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/satisfactory-server/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/satisfactory-server/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/satisfactory-server/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/satisfactory-server/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/satisfactory-server/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/satisfactory-server/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/satisfactory-server/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/satisfactory-server/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/satisfactory-server/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/satisfactory-server/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/satisfactory-server/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/satisfactory-server/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/ports.py rename ix-dev/community/satisfactory-server/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/satisfactory-server/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/satisfactory-server/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/satisfactory-server/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/satisfactory-server/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/satisfactory-server/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/satisfactory-server/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/satisfactory-server/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/satisfactory-server/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/satisfactory-server/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/satisfactory-server/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/satisfactory-server/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/satisfactory-server/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/satisfactory-server/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/satisfactory-server/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/satisfactory-server/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/satisfactory-server/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/satisfactory-server/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/satisfactory-server/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/satisfactory-server/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/satisfactory-server/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/satisfactory-server/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/satisfactory-server/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/satisfactory-server/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/satisfactory-server/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/satisfactory-server/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/satisfactory-server/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/satisfactory-server/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/satisfactory-server/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/satisfactory-server/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/satisfactory-server/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/satisfactory-server/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/scrutiny/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/scrutiny/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/scrutiny/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/scrutiny/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/scrutiny/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/scrutiny/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/scrutiny/templates/library/base_v2_1_9/container.py rename ix-dev/community/scrutiny/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/scrutiny/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/scrutiny/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/scrutiny/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/scrutiny/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/scrutiny/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/scrutiny/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/scrutiny/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/scrutiny/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/scrutiny/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/scrutiny/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/scrutiny/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/scrutiny/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/scrutiny/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/scrutiny/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/scrutiny/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/scrutiny/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/scrutiny/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/scrutiny/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/scrutiny/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/scrutiny/templates/library/base_v2_1_9/ports.py rename ix-dev/community/scrutiny/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/scrutiny/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/scrutiny/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/scrutiny/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/scrutiny/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/scrutiny/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/scrutiny/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/scrutiny/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/scrutiny/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/scrutiny/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/scrutiny/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/scrutiny/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/scrutiny/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/scrutiny/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/scrutiny/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/scrutiny/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/scrutiny/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/scrutiny/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/scrutiny/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/scrutiny/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/scrutiny/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/scrutiny/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/scrutiny/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/scrutiny/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/scrutiny/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/scrutiny/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/scrutiny/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/scrutiny/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/scrutiny/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/scrutiny/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/scrutiny/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/scrutiny/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/scrutiny/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/scrutiny/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/searxng/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/searxng/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/searxng/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/searxng/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/searxng/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/searxng/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/searxng/templates/library/base_v2_1_9/container.py rename ix-dev/community/searxng/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/searxng/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/searxng/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/searxng/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/searxng/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/searxng/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/searxng/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/searxng/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/searxng/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/searxng/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/searxng/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/searxng/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/searxng/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/searxng/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/searxng/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/searxng/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/searxng/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/searxng/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/searxng/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/searxng/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/searxng/templates/library/base_v2_1_9/ports.py rename ix-dev/community/searxng/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/searxng/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/searxng/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/searxng/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/searxng/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/searxng/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/searxng/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/searxng/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/searxng/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/searxng/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/searxng/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/searxng/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/searxng/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/searxng/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/searxng/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/searxng/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/searxng/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/searxng/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/searxng/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/searxng/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/searxng/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/searxng/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/searxng/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/searxng/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/searxng/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/searxng/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/searxng/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/searxng/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/searxng/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/searxng/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/searxng/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/searxng/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/searxng/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/searxng/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/sftpgo/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/sftpgo/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/sftpgo/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/sftpgo/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/sftpgo/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/sftpgo/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/sftpgo/templates/library/base_v2_1_9/container.py rename ix-dev/community/sftpgo/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/sftpgo/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/sftpgo/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/sftpgo/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/sftpgo/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/sftpgo/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/sftpgo/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/sftpgo/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/sftpgo/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/sftpgo/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/sftpgo/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/sftpgo/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/sftpgo/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/sftpgo/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/sftpgo/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/sftpgo/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/sftpgo/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/sftpgo/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/sftpgo/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/sftpgo/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/sftpgo/templates/library/base_v2_1_9/ports.py rename ix-dev/community/sftpgo/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/sftpgo/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/sftpgo/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/sftpgo/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/sftpgo/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/sftpgo/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/sftpgo/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/sftpgo/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/sftpgo/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/sftpgo/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/sftpgo/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/sftpgo/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/sftpgo/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/sftpgo/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/sftpgo/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/sftpgo/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/sftpgo/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/sftpgo/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/sftpgo/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/sftpgo/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/sftpgo/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/sftpgo/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/sftpgo/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/sftpgo/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/sftpgo/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/sftpgo/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/sftpgo/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/sftpgo/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/sftpgo/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/sftpgo/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/sftpgo/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/sftpgo/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/sftpgo/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/sftpgo/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/sonarr/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/sonarr/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/sonarr/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/sonarr/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/sonarr/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/sonarr/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/sonarr/templates/library/base_v2_1_9/container.py rename ix-dev/community/sonarr/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/sonarr/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/sonarr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/sonarr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/sonarr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/sonarr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/sonarr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/sonarr/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/sonarr/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/sonarr/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/sonarr/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/sonarr/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/sonarr/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/sonarr/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/sonarr/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/sonarr/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/sonarr/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/sonarr/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/sonarr/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/sonarr/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/sonarr/templates/library/base_v2_1_9/ports.py rename ix-dev/community/sonarr/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/sonarr/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/sonarr/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/sonarr/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/sonarr/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/sonarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/sonarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/sonarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/sonarr/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/sonarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/sonarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/sonarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/sonarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/sonarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/sonarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/sonarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/sonarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/sonarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/sonarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/sonarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/sonarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/sonarr/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/sonarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/sonarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/sonarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/sonarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/sonarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/sonarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/sonarr/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/sonarr/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/sonarr/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/sonarr/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/sonarr/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/sonarr/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/tailscale/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/tailscale/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/tailscale/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/tailscale/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/tailscale/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/tailscale/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/tailscale/templates/library/base_v2_1_9/container.py rename ix-dev/community/tailscale/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/tailscale/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/tailscale/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/tailscale/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/tailscale/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/tailscale/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/tailscale/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/tailscale/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/tailscale/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/tailscale/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/tailscale/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/tailscale/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/tailscale/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/tailscale/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/tailscale/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/tailscale/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/tailscale/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/tailscale/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/tailscale/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/tailscale/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/tailscale/templates/library/base_v2_1_9/ports.py rename ix-dev/community/tailscale/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/tailscale/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/tailscale/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/tailscale/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/tailscale/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/tailscale/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/tailscale/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/tailscale/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/tailscale/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/tailscale/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/tailscale/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/tailscale/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/tailscale/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/tailscale/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/tailscale/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/tailscale/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/tailscale/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/tailscale/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/tailscale/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/tailscale/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/tailscale/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/tailscale/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/tailscale/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/tailscale/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/tailscale/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/tailscale/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/tailscale/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/tailscale/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/tailscale/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/tailscale/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/tailscale/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/tailscale/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/tailscale/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/tailscale/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/tautulli/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/tautulli/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/tautulli/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/tautulli/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/tautulli/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/tautulli/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/tautulli/templates/library/base_v2_1_9/container.py rename ix-dev/community/tautulli/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/tautulli/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/tautulli/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/tautulli/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/tautulli/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/tautulli/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/tautulli/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/tautulli/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/tautulli/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/tautulli/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/tautulli/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/tautulli/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/tautulli/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/tautulli/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/tautulli/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/tautulli/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/tautulli/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/tautulli/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/tautulli/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/tautulli/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/tautulli/templates/library/base_v2_1_9/ports.py rename ix-dev/community/tautulli/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/tautulli/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/tautulli/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/tautulli/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/tautulli/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/tautulli/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/tautulli/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/tautulli/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/tautulli/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/tautulli/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/tautulli/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/tautulli/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/tautulli/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/tautulli/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/tautulli/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/tautulli/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/tautulli/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/tautulli/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/tautulli/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/tautulli/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/tautulli/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/tautulli/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/tautulli/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/tautulli/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/tautulli/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/tautulli/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/tautulli/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/tautulli/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/tautulli/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/tautulli/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/tautulli/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/tautulli/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/tautulli/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/tautulli/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/tdarr/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/tdarr/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/tdarr/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/tdarr/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/tdarr/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/tdarr/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/tdarr/templates/library/base_v2_1_9/container.py rename ix-dev/community/tdarr/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/tdarr/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/tdarr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/tdarr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/tdarr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/tdarr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/tdarr/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/tdarr/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/tdarr/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/tdarr/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/tdarr/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/tdarr/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/tdarr/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/tdarr/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/tdarr/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/tdarr/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/tdarr/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/tdarr/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/tdarr/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/tdarr/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/tdarr/templates/library/base_v2_1_9/ports.py rename ix-dev/community/tdarr/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/tdarr/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/tdarr/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/tdarr/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/tdarr/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/tdarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/tdarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/tdarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/tdarr/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/tdarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/tdarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/tdarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/tdarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/tdarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/tdarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/tdarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/tdarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/tdarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/tdarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/tdarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/tdarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/tdarr/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/tdarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/tdarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/tdarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/tdarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/tdarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/tdarr/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/tdarr/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/tdarr/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/tdarr/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/tdarr/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/tdarr/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/tdarr/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/terraria/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/terraria/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/terraria/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/terraria/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/terraria/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/terraria/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/terraria/templates/library/base_v2_1_9/container.py rename ix-dev/community/terraria/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/terraria/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/terraria/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/terraria/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/terraria/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/terraria/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/terraria/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/terraria/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/terraria/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/terraria/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/terraria/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/terraria/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/terraria/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/terraria/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/terraria/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/terraria/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/terraria/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/terraria/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/terraria/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/terraria/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/terraria/templates/library/base_v2_1_9/ports.py rename ix-dev/community/terraria/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/terraria/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/terraria/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/terraria/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/terraria/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/terraria/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/terraria/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/terraria/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/terraria/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/terraria/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/terraria/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/terraria/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/terraria/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/terraria/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/terraria/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/terraria/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/terraria/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/terraria/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/terraria/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/terraria/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/terraria/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/terraria/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/terraria/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/terraria/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/terraria/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/terraria/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/terraria/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/terraria/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/terraria/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/terraria/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/terraria/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/terraria/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/terraria/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/terraria/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/tftpd-hpa/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/tftpd-hpa/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/container.py rename ix-dev/community/tftpd-hpa/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/tftpd-hpa/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/tftpd-hpa/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/tftpd-hpa/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/tftpd-hpa/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/tftpd-hpa/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/tftpd-hpa/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/tftpd-hpa/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/tftpd-hpa/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/tftpd-hpa/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/tftpd-hpa/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/tftpd-hpa/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/tftpd-hpa/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/tftpd-hpa/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/tftpd-hpa/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/tftpd-hpa/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/tftpd-hpa/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/tftpd-hpa/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/tftpd-hpa/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/tftpd-hpa/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/ports.py rename ix-dev/community/tftpd-hpa/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/tftpd-hpa/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/tftpd-hpa/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/tftpd-hpa/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/tftpd-hpa/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/tftpd-hpa/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/tftpd-hpa/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/tftpd-hpa/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/tftpd-hpa/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/tftpd-hpa/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/tftpd-hpa/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/tftpd-hpa/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/tftpd-hpa/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/tftpd-hpa/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/tftpd-hpa/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/tftpd-hpa/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/tftpd-hpa/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/tftpd-hpa/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/tftpd-hpa/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/tftpd-hpa/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/tftpd-hpa/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/tftpd-hpa/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/tftpd-hpa/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/tftpd-hpa/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/tftpd-hpa/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/tftpd-hpa/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/tftpd-hpa/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/tftpd-hpa/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/tftpd-hpa/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/tftpd-hpa/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/tftpd-hpa/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/tftpd-hpa/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/tianji/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/tianji/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/tianji/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/tianji/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/tianji/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/tianji/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/tianji/templates/library/base_v2_1_9/container.py rename ix-dev/community/tianji/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/tianji/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/tianji/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/tianji/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/tianji/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/tianji/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/tianji/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/tianji/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/tianji/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/tianji/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/tianji/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/tianji/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/tianji/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/tianji/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/tianji/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/tianji/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/tianji/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/tianji/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/tianji/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/tianji/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/tianji/templates/library/base_v2_1_9/ports.py rename ix-dev/community/tianji/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/tianji/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/tianji/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/tianji/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/tianji/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/tianji/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/tianji/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/tianji/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/tianji/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/tianji/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/tianji/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/tianji/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/tianji/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/tianji/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/tianji/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/tianji/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/tianji/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/tianji/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/tianji/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/tianji/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/tianji/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/tianji/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/tianji/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/tianji/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/tianji/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/tianji/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/tianji/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/tianji/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/tianji/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/tianji/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/tianji/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/tianji/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/tianji/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/tianji/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/tiny-media-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/tiny-media-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/container.py rename ix-dev/community/tiny-media-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/tiny-media-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/tiny-media-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/tiny-media-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/tiny-media-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/tiny-media-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/tiny-media-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/tiny-media-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/tiny-media-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/tiny-media-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/tiny-media-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/tiny-media-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/tiny-media-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/tiny-media-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/tiny-media-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/tiny-media-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/tiny-media-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/tiny-media-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/tiny-media-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/tiny-media-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/ports.py rename ix-dev/community/tiny-media-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/tiny-media-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/tiny-media-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/tiny-media-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/tiny-media-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/tiny-media-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/tiny-media-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/tiny-media-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/tiny-media-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/tiny-media-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/tiny-media-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/tiny-media-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/tiny-media-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/tiny-media-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/tiny-media-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/tiny-media-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/tiny-media-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/tiny-media-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/tiny-media-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/tiny-media-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/tiny-media-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/tiny-media-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/tiny-media-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/tiny-media-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/tiny-media-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/tiny-media-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/tiny-media-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/tiny-media-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/tiny-media-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/tiny-media-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/tiny-media-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/tiny-media-manager/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/transmission/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/transmission/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/transmission/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/transmission/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/transmission/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/transmission/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/transmission/templates/library/base_v2_1_9/container.py rename ix-dev/community/transmission/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/transmission/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/transmission/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/transmission/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/transmission/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/transmission/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/transmission/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/transmission/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/transmission/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/transmission/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/transmission/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/transmission/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/transmission/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/transmission/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/transmission/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/transmission/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/transmission/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/transmission/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/transmission/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/transmission/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/transmission/templates/library/base_v2_1_9/ports.py rename ix-dev/community/transmission/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/transmission/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/transmission/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/transmission/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/transmission/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/transmission/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/transmission/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/transmission/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/transmission/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/transmission/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/transmission/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/transmission/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/transmission/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/transmission/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/transmission/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/transmission/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/transmission/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/transmission/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/transmission/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/transmission/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/transmission/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/transmission/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/transmission/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/transmission/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/transmission/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/transmission/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/transmission/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/transmission/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/transmission/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/transmission/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/transmission/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/transmission/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/transmission/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/transmission/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/twofactor-auth/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/twofactor-auth/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/container.py rename ix-dev/community/twofactor-auth/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/twofactor-auth/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/twofactor-auth/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/twofactor-auth/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/twofactor-auth/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/twofactor-auth/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/twofactor-auth/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/twofactor-auth/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/twofactor-auth/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/twofactor-auth/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/twofactor-auth/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/twofactor-auth/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/twofactor-auth/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/twofactor-auth/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/twofactor-auth/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/twofactor-auth/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/twofactor-auth/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/twofactor-auth/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/twofactor-auth/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/twofactor-auth/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/ports.py rename ix-dev/community/twofactor-auth/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/twofactor-auth/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/twofactor-auth/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/twofactor-auth/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/twofactor-auth/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/twofactor-auth/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/twofactor-auth/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/twofactor-auth/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/twofactor-auth/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/twofactor-auth/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/twofactor-auth/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/twofactor-auth/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/twofactor-auth/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/twofactor-auth/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/twofactor-auth/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/twofactor-auth/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/twofactor-auth/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/twofactor-auth/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/twofactor-auth/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/twofactor-auth/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/twofactor-auth/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/twofactor-auth/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/twofactor-auth/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/twofactor-auth/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/twofactor-auth/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/twofactor-auth/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/twofactor-auth/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/twofactor-auth/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/twofactor-auth/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/twofactor-auth/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/twofactor-auth/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/twofactor-auth/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/umami/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/umami/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/umami/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/umami/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/umami/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/umami/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/umami/templates/library/base_v2_1_9/container.py rename ix-dev/community/umami/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/umami/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/umami/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/umami/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/umami/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/umami/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/umami/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/umami/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/umami/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/umami/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/umami/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/umami/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/umami/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/umami/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/umami/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/umami/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/umami/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/umami/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/umami/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/umami/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/umami/templates/library/base_v2_1_9/ports.py rename ix-dev/community/umami/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/umami/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/umami/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/umami/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/umami/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/umami/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/umami/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/umami/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/umami/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/umami/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/umami/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/umami/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/umami/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/umami/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/umami/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/umami/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/umami/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/umami/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/umami/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/umami/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/umami/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/umami/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/umami/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/umami/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/umami/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/umami/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/umami/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/umami/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/umami/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/umami/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/umami/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/umami/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/umami/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/umami/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/unifi-controller/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/unifi-controller/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/unifi-controller/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/unifi-controller/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/unifi-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/unifi-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/unifi-controller/templates/library/base_v2_1_9/container.py rename ix-dev/community/unifi-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/unifi-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/unifi-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/unifi-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/unifi-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/unifi-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/unifi-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/unifi-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/unifi-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/unifi-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/unifi-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/unifi-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/unifi-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/unifi-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/unifi-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/unifi-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/unifi-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/unifi-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/unifi-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/unifi-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/unifi-controller/templates/library/base_v2_1_9/ports.py rename ix-dev/community/unifi-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/unifi-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/unifi-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/unifi-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/unifi-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/unifi-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/unifi-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/unifi-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/unifi-controller/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/unifi-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/unifi-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/unifi-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/unifi-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/unifi-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/unifi-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/unifi-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/unifi-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/unifi-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/unifi-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/unifi-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/unifi-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/unifi-controller/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/unifi-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/unifi-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/unifi-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/unifi-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/unifi-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/unifi-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/unifi-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/unifi-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/unifi-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/unifi-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/unifi-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/unifi-controller/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/unifi-protect-backup/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/unifi-protect-backup/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/container.py rename ix-dev/community/unifi-protect-backup/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/unifi-protect-backup/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/unifi-protect-backup/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/unifi-protect-backup/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/unifi-protect-backup/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/unifi-protect-backup/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/unifi-protect-backup/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/unifi-protect-backup/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/unifi-protect-backup/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/unifi-protect-backup/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/unifi-protect-backup/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/unifi-protect-backup/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/unifi-protect-backup/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/unifi-protect-backup/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/unifi-protect-backup/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/unifi-protect-backup/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/unifi-protect-backup/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/unifi-protect-backup/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/unifi-protect-backup/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/unifi-protect-backup/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/ports.py rename ix-dev/community/unifi-protect-backup/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/unifi-protect-backup/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/unifi-protect-backup/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/unifi-protect-backup/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/unifi-protect-backup/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/unifi-protect-backup/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/unifi-protect-backup/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/unifi-protect-backup/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/unifi-protect-backup/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/unifi-protect-backup/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/unifi-protect-backup/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/unifi-protect-backup/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/unifi-protect-backup/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/unifi-protect-backup/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/unifi-protect-backup/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/unifi-protect-backup/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/unifi-protect-backup/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/unifi-protect-backup/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/unifi-protect-backup/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/unifi-protect-backup/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/unifi-protect-backup/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/unifi-protect-backup/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/unifi-protect-backup/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/unifi-protect-backup/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/unifi-protect-backup/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/unifi-protect-backup/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/unifi-protect-backup/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/unifi-protect-backup/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/unifi-protect-backup/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/unifi-protect-backup/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/unifi-protect-backup/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/unifi-protect-backup/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/uptime-kuma/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/uptime-kuma/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/container.py rename ix-dev/community/uptime-kuma/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/uptime-kuma/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/uptime-kuma/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/uptime-kuma/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/uptime-kuma/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/uptime-kuma/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/uptime-kuma/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/uptime-kuma/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/uptime-kuma/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/uptime-kuma/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/uptime-kuma/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/uptime-kuma/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/uptime-kuma/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/uptime-kuma/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/uptime-kuma/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/uptime-kuma/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/uptime-kuma/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/uptime-kuma/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/uptime-kuma/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/uptime-kuma/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/ports.py rename ix-dev/community/uptime-kuma/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/uptime-kuma/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/uptime-kuma/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/uptime-kuma/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/uptime-kuma/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/uptime-kuma/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/uptime-kuma/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/uptime-kuma/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/uptime-kuma/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/uptime-kuma/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/uptime-kuma/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/uptime-kuma/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/uptime-kuma/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/uptime-kuma/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/uptime-kuma/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/uptime-kuma/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/uptime-kuma/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/uptime-kuma/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/uptime-kuma/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/uptime-kuma/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/uptime-kuma/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/uptime-kuma/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/uptime-kuma/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/uptime-kuma/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/uptime-kuma/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/uptime-kuma/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/uptime-kuma/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/uptime-kuma/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/uptime-kuma/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/uptime-kuma/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/uptime-kuma/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/uptime-kuma/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/vaultwarden/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/vaultwarden/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/vaultwarden/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/vaultwarden/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/vaultwarden/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/vaultwarden/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/vaultwarden/templates/library/base_v2_1_9/container.py rename ix-dev/community/vaultwarden/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/vaultwarden/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/vaultwarden/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/vaultwarden/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/vaultwarden/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/vaultwarden/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/vaultwarden/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/vaultwarden/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/vaultwarden/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/vaultwarden/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/vaultwarden/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/vaultwarden/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/vaultwarden/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/vaultwarden/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/vaultwarden/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/vaultwarden/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/vaultwarden/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/vaultwarden/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/vaultwarden/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/vaultwarden/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/vaultwarden/templates/library/base_v2_1_9/ports.py rename ix-dev/community/vaultwarden/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/vaultwarden/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/vaultwarden/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/vaultwarden/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/vaultwarden/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/vaultwarden/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/vaultwarden/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/vaultwarden/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/vaultwarden/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/vaultwarden/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/vaultwarden/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/vaultwarden/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/vaultwarden/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/vaultwarden/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/vaultwarden/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/vaultwarden/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/vaultwarden/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/vaultwarden/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/vaultwarden/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/vaultwarden/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/vaultwarden/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/vaultwarden/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/vaultwarden/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/vaultwarden/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/vaultwarden/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/vaultwarden/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/vaultwarden/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/vaultwarden/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/vaultwarden/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/vaultwarden/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/vaultwarden/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/vaultwarden/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/vaultwarden/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/vaultwarden/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/vikunja/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/vikunja/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/vikunja/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/vikunja/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/vikunja/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/vikunja/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/vikunja/templates/library/base_v2_1_9/container.py rename ix-dev/community/vikunja/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/vikunja/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/vikunja/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/vikunja/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/vikunja/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/vikunja/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/vikunja/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/vikunja/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/vikunja/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/vikunja/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/vikunja/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/vikunja/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/vikunja/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/vikunja/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/vikunja/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/vikunja/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/vikunja/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/vikunja/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/vikunja/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/vikunja/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/vikunja/templates/library/base_v2_1_9/ports.py rename ix-dev/community/vikunja/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/vikunja/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/vikunja/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/vikunja/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/vikunja/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/vikunja/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/vikunja/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/vikunja/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/vikunja/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/vikunja/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/vikunja/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/vikunja/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/vikunja/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/vikunja/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/vikunja/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/vikunja/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/vikunja/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/vikunja/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/vikunja/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/vikunja/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/vikunja/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/vikunja/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/vikunja/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/vikunja/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/vikunja/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/vikunja/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/vikunja/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/vikunja/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/vikunja/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/vikunja/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/vikunja/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/vikunja/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/vikunja/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/vikunja/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/webdav/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/webdav/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/webdav/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/webdav/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/webdav/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/webdav/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/webdav/templates/library/base_v2_1_9/container.py rename ix-dev/community/webdav/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/webdav/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/webdav/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/webdav/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/webdav/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/webdav/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/webdav/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/webdav/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/webdav/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/webdav/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/webdav/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/webdav/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/webdav/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/webdav/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/webdav/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/webdav/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/webdav/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/webdav/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/webdav/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/webdav/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/webdav/templates/library/base_v2_1_9/ports.py rename ix-dev/community/webdav/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/webdav/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/webdav/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/webdav/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/webdav/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/webdav/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/webdav/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/webdav/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/webdav/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/webdav/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/webdav/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/webdav/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/webdav/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/webdav/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/webdav/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/webdav/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/webdav/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/webdav/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/webdav/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/webdav/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/webdav/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/webdav/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/webdav/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/webdav/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/webdav/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/webdav/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/webdav/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/webdav/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/webdav/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/webdav/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/webdav/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/webdav/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/webdav/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/webdav/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/whoogle/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/whoogle/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/whoogle/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/whoogle/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/whoogle/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/whoogle/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/whoogle/templates/library/base_v2_1_9/container.py rename ix-dev/community/whoogle/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/whoogle/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/whoogle/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/whoogle/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/whoogle/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/whoogle/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/whoogle/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/whoogle/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/whoogle/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/whoogle/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/whoogle/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/whoogle/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/whoogle/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/whoogle/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/whoogle/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/whoogle/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/whoogle/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/whoogle/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/whoogle/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/whoogle/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/whoogle/templates/library/base_v2_1_9/ports.py rename ix-dev/community/whoogle/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/whoogle/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/whoogle/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/whoogle/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/whoogle/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/whoogle/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/whoogle/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/whoogle/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/whoogle/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/whoogle/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/whoogle/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/whoogle/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/whoogle/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/whoogle/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/whoogle/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/whoogle/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/whoogle/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/whoogle/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/whoogle/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/whoogle/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/whoogle/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/whoogle/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/whoogle/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/whoogle/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/whoogle/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/whoogle/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/whoogle/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/whoogle/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/whoogle/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/whoogle/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/whoogle/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/whoogle/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/whoogle/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/whoogle/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/wordpress/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/wordpress/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/wordpress/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/wordpress/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/wordpress/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/wordpress/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/wordpress/templates/library/base_v2_1_9/container.py rename ix-dev/community/wordpress/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/wordpress/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/wordpress/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/wordpress/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/wordpress/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/wordpress/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/wordpress/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/wordpress/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/wordpress/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/wordpress/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/wordpress/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/wordpress/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/wordpress/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/wordpress/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/wordpress/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/wordpress/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/wordpress/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/wordpress/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/wordpress/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/wordpress/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/wordpress/templates/library/base_v2_1_9/ports.py rename ix-dev/community/wordpress/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/wordpress/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/wordpress/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/wordpress/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/wordpress/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/wordpress/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/wordpress/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/wordpress/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/wordpress/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/wordpress/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/wordpress/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/wordpress/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/wordpress/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/wordpress/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/wordpress/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/wordpress/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/wordpress/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/wordpress/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/wordpress/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/wordpress/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/wordpress/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/wordpress/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/wordpress/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/wordpress/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/wordpress/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/wordpress/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/wordpress/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/wordpress/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/wordpress/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/wordpress/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/wordpress/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/wordpress/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/wordpress/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/wordpress/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/zerotier/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/zerotier/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/zerotier/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/zerotier/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/zerotier/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/zerotier/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/zerotier/templates/library/base_v2_1_9/container.py rename ix-dev/community/zerotier/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/zerotier/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/zerotier/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/zerotier/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/zerotier/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/zerotier/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/zerotier/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/zerotier/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/zerotier/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/zerotier/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/zerotier/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/zerotier/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/zerotier/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/zerotier/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/zerotier/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/zerotier/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/zerotier/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/zerotier/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/zerotier/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/zerotier/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/zerotier/templates/library/base_v2_1_9/ports.py rename ix-dev/community/zerotier/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/zerotier/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/zerotier/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/zerotier/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/zerotier/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/zerotier/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/zerotier/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/zerotier/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/zerotier/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/zerotier/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/zerotier/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/zerotier/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/zerotier/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/zerotier/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/zerotier/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/zerotier/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/zerotier/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/zerotier/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/zerotier/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/zerotier/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/zerotier/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/zerotier/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/zerotier/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/zerotier/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/zerotier/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/zerotier/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/zerotier/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/zerotier/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/zerotier/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/zerotier/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/zerotier/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/zerotier/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/zerotier/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/zerotier/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/community/zigbee2mqtt/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/community/zigbee2mqtt/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/container.py rename ix-dev/community/zigbee2mqtt/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/community/zigbee2mqtt/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/community/zigbee2mqtt/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/community/zigbee2mqtt/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/community/zigbee2mqtt/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/community/zigbee2mqtt/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/community/zigbee2mqtt/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/community/zigbee2mqtt/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/community/zigbee2mqtt/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/community/zigbee2mqtt/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/community/zigbee2mqtt/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/community/zigbee2mqtt/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/community/zigbee2mqtt/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/community/zigbee2mqtt/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/community/zigbee2mqtt/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/community/zigbee2mqtt/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/community/zigbee2mqtt/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/community/zigbee2mqtt/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/community/zigbee2mqtt/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/community/zigbee2mqtt/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/ports.py rename ix-dev/community/zigbee2mqtt/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/community/zigbee2mqtt/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/community/zigbee2mqtt/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/community/zigbee2mqtt/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/community/zigbee2mqtt/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/community/zigbee2mqtt/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/community/zigbee2mqtt/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/community/zigbee2mqtt/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/community/zigbee2mqtt/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/community/zigbee2mqtt/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/community/zigbee2mqtt/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/community/zigbee2mqtt/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/community/zigbee2mqtt/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/community/zigbee2mqtt/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/community/zigbee2mqtt/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/community/zigbee2mqtt/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/community/zigbee2mqtt/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/community/zigbee2mqtt/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/community/zigbee2mqtt/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/community/zigbee2mqtt/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/community/zigbee2mqtt/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/community/zigbee2mqtt/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/community/zigbee2mqtt/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/community/zigbee2mqtt/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/community/zigbee2mqtt/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/community/zigbee2mqtt/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/community/zigbee2mqtt/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/community/zigbee2mqtt/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/community/zigbee2mqtt/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/community/zigbee2mqtt/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/community/zigbee2mqtt/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/community/zigbee2mqtt/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/enterprise/asigra-ds-system/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/enterprise/asigra-ds-system/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/container.py rename ix-dev/enterprise/asigra-ds-system/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/enterprise/asigra-ds-system/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/enterprise/asigra-ds-system/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/enterprise/asigra-ds-system/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/enterprise/asigra-ds-system/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/enterprise/asigra-ds-system/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/enterprise/asigra-ds-system/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/enterprise/asigra-ds-system/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/enterprise/asigra-ds-system/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/enterprise/asigra-ds-system/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/enterprise/asigra-ds-system/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/enterprise/asigra-ds-system/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/enterprise/asigra-ds-system/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/enterprise/asigra-ds-system/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/enterprise/asigra-ds-system/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/enterprise/asigra-ds-system/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/enterprise/asigra-ds-system/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/enterprise/asigra-ds-system/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/enterprise/asigra-ds-system/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/enterprise/asigra-ds-system/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/ports.py rename ix-dev/enterprise/asigra-ds-system/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/enterprise/asigra-ds-system/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/enterprise/asigra-ds-system/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/enterprise/asigra-ds-system/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/enterprise/asigra-ds-system/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/enterprise/asigra-ds-system/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/enterprise/asigra-ds-system/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/enterprise/asigra-ds-system/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/enterprise/asigra-ds-system/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/enterprise/asigra-ds-system/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/enterprise/asigra-ds-system/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/enterprise/asigra-ds-system/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/enterprise/asigra-ds-system/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/enterprise/asigra-ds-system/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/enterprise/asigra-ds-system/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/enterprise/asigra-ds-system/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/enterprise/asigra-ds-system/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/enterprise/asigra-ds-system/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/enterprise/asigra-ds-system/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/enterprise/asigra-ds-system/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/enterprise/asigra-ds-system/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/enterprise/asigra-ds-system/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/enterprise/asigra-ds-system/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/enterprise/asigra-ds-system/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/enterprise/asigra-ds-system/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/enterprise/asigra-ds-system/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/enterprise/asigra-ds-system/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/enterprise/asigra-ds-system/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/enterprise/asigra-ds-system/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/enterprise/asigra-ds-system/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/enterprise/asigra-ds-system/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/enterprise/asigra-ds-system/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/enterprise/minio/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/enterprise/minio/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/enterprise/minio/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/enterprise/minio/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/enterprise/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/enterprise/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/enterprise/minio/templates/library/base_v2_1_9/container.py rename ix-dev/enterprise/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/enterprise/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/enterprise/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/enterprise/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/enterprise/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/enterprise/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/enterprise/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/enterprise/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/enterprise/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/enterprise/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/enterprise/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/enterprise/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/enterprise/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/enterprise/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/enterprise/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/enterprise/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/enterprise/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/enterprise/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/enterprise/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/enterprise/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/enterprise/minio/templates/library/base_v2_1_9/ports.py rename ix-dev/enterprise/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/enterprise/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/enterprise/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/enterprise/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/enterprise/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/enterprise/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/enterprise/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/enterprise/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/enterprise/minio/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/enterprise/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/enterprise/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/enterprise/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/enterprise/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/enterprise/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/enterprise/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/enterprise/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/enterprise/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/enterprise/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/enterprise/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/enterprise/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/enterprise/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/enterprise/minio/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/enterprise/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/enterprise/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/enterprise/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/enterprise/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/enterprise/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/enterprise/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/enterprise/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/enterprise/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/enterprise/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/enterprise/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/enterprise/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/enterprise/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/enterprise/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/enterprise/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/container.py rename ix-dev/enterprise/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/enterprise/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/enterprise/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/enterprise/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/enterprise/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/enterprise/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/enterprise/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/enterprise/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/enterprise/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/enterprise/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/enterprise/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/enterprise/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/enterprise/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/enterprise/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/enterprise/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/enterprise/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/enterprise/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/enterprise/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/enterprise/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/enterprise/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/ports.py rename ix-dev/enterprise/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/enterprise/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/enterprise/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/enterprise/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/enterprise/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/enterprise/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/enterprise/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/enterprise/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/enterprise/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/enterprise/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/enterprise/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/enterprise/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/enterprise/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/enterprise/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/enterprise/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/enterprise/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/enterprise/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/enterprise/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/enterprise/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/enterprise/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/enterprise/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/enterprise/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/enterprise/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/enterprise/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/enterprise/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/enterprise/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/enterprise/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/enterprise/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/enterprise/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/enterprise/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/enterprise/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/enterprise/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/stable/collabora/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/stable/collabora/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/stable/collabora/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/stable/collabora/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/stable/collabora/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/stable/collabora/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/stable/collabora/templates/library/base_v2_1_9/container.py rename ix-dev/stable/collabora/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/stable/collabora/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/stable/collabora/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/stable/collabora/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/stable/collabora/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/stable/collabora/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/stable/collabora/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/stable/collabora/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/stable/collabora/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/stable/collabora/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/stable/collabora/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/stable/collabora/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/stable/collabora/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/stable/collabora/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/stable/collabora/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/stable/collabora/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/stable/collabora/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/stable/collabora/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/stable/collabora/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/stable/collabora/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/stable/collabora/templates/library/base_v2_1_9/ports.py rename ix-dev/stable/collabora/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/stable/collabora/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/stable/collabora/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/stable/collabora/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/stable/collabora/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/stable/collabora/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/stable/collabora/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/stable/collabora/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/stable/collabora/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/stable/collabora/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/stable/collabora/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/stable/collabora/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/stable/collabora/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/stable/collabora/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/stable/collabora/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/stable/collabora/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/stable/collabora/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/stable/collabora/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/stable/collabora/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/stable/collabora/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/stable/collabora/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/stable/collabora/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/stable/collabora/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/stable/collabora/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/stable/collabora/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/stable/collabora/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/stable/collabora/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/stable/collabora/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/stable/collabora/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/stable/collabora/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/stable/collabora/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/stable/collabora/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/stable/collabora/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/stable/collabora/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/stable/diskoverdata/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/stable/diskoverdata/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/container.py rename ix-dev/stable/diskoverdata/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/stable/diskoverdata/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/stable/diskoverdata/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/stable/diskoverdata/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/stable/diskoverdata/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/stable/diskoverdata/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/stable/diskoverdata/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/stable/diskoverdata/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/stable/diskoverdata/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/stable/diskoverdata/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/stable/diskoverdata/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/stable/diskoverdata/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/stable/diskoverdata/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/stable/diskoverdata/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/stable/diskoverdata/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/stable/diskoverdata/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/stable/diskoverdata/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/stable/diskoverdata/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/stable/diskoverdata/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/stable/diskoverdata/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/ports.py rename ix-dev/stable/diskoverdata/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/stable/diskoverdata/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/stable/diskoverdata/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/stable/diskoverdata/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/stable/diskoverdata/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/stable/diskoverdata/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/stable/diskoverdata/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/stable/diskoverdata/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/stable/diskoverdata/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/stable/diskoverdata/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/stable/diskoverdata/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/stable/diskoverdata/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/stable/diskoverdata/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/stable/diskoverdata/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/stable/diskoverdata/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/stable/diskoverdata/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/stable/diskoverdata/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/stable/diskoverdata/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/stable/diskoverdata/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/stable/diskoverdata/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/stable/diskoverdata/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/stable/diskoverdata/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/stable/diskoverdata/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/stable/diskoverdata/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/stable/diskoverdata/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/stable/diskoverdata/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/stable/diskoverdata/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/stable/diskoverdata/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/stable/diskoverdata/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/stable/diskoverdata/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/stable/diskoverdata/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/stable/diskoverdata/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/stable/elastic-search/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/stable/elastic-search/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/stable/elastic-search/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/stable/elastic-search/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/stable/elastic-search/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/stable/elastic-search/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/stable/elastic-search/templates/library/base_v2_1_9/container.py rename ix-dev/stable/elastic-search/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/stable/elastic-search/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/stable/elastic-search/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/stable/elastic-search/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/stable/elastic-search/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/stable/elastic-search/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/stable/elastic-search/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/stable/elastic-search/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/stable/elastic-search/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/stable/elastic-search/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/stable/elastic-search/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/stable/elastic-search/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/stable/elastic-search/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/stable/elastic-search/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/stable/elastic-search/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/stable/elastic-search/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/stable/elastic-search/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/stable/elastic-search/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/stable/elastic-search/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/stable/elastic-search/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/stable/elastic-search/templates/library/base_v2_1_9/ports.py rename ix-dev/stable/elastic-search/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/stable/elastic-search/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/stable/elastic-search/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/stable/elastic-search/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/stable/elastic-search/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/stable/elastic-search/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/stable/elastic-search/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/stable/elastic-search/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/stable/elastic-search/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/stable/elastic-search/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/stable/elastic-search/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/stable/elastic-search/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/stable/elastic-search/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/stable/elastic-search/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/stable/elastic-search/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/stable/elastic-search/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/stable/elastic-search/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/stable/elastic-search/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/stable/elastic-search/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/stable/elastic-search/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/stable/elastic-search/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/stable/elastic-search/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/stable/elastic-search/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/stable/elastic-search/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/stable/elastic-search/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/stable/elastic-search/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/stable/elastic-search/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/stable/elastic-search/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/stable/elastic-search/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/stable/elastic-search/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/stable/elastic-search/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/stable/elastic-search/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/stable/elastic-search/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/stable/elastic-search/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/stable/emby/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/stable/emby/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/stable/emby/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/stable/emby/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/stable/emby/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/stable/emby/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/stable/emby/templates/library/base_v2_1_9/container.py rename ix-dev/stable/emby/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/stable/emby/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/stable/emby/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/stable/emby/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/stable/emby/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/stable/emby/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/stable/emby/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/stable/emby/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/stable/emby/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/stable/emby/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/stable/emby/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/stable/emby/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/stable/emby/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/stable/emby/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/stable/emby/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/stable/emby/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/stable/emby/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/stable/emby/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/stable/emby/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/stable/emby/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/stable/emby/templates/library/base_v2_1_9/ports.py rename ix-dev/stable/emby/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/stable/emby/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/stable/emby/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/stable/emby/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/stable/emby/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/stable/emby/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/stable/emby/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/stable/emby/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/stable/emby/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/stable/emby/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/stable/emby/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/stable/emby/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/stable/emby/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/stable/emby/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/stable/emby/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/stable/emby/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/stable/emby/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/stable/emby/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/stable/emby/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/stable/emby/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/stable/emby/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/stable/emby/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/stable/emby/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/stable/emby/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/stable/emby/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/stable/emby/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/stable/emby/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/stable/emby/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/stable/emby/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/stable/emby/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/stable/emby/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/stable/emby/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/stable/emby/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/stable/emby/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/stable/home-assistant/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/stable/home-assistant/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/stable/home-assistant/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/stable/home-assistant/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/stable/home-assistant/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/stable/home-assistant/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/stable/home-assistant/templates/library/base_v2_1_9/container.py rename ix-dev/stable/home-assistant/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/stable/home-assistant/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/stable/home-assistant/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/stable/home-assistant/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/stable/home-assistant/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/stable/home-assistant/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/stable/home-assistant/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/stable/home-assistant/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/stable/home-assistant/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/stable/home-assistant/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/stable/home-assistant/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/stable/home-assistant/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/stable/home-assistant/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/stable/home-assistant/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/stable/home-assistant/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/stable/home-assistant/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/stable/home-assistant/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/stable/home-assistant/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/stable/home-assistant/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/stable/home-assistant/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/stable/home-assistant/templates/library/base_v2_1_9/ports.py rename ix-dev/stable/home-assistant/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/stable/home-assistant/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/stable/home-assistant/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/stable/home-assistant/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/stable/home-assistant/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/stable/home-assistant/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/stable/home-assistant/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/stable/home-assistant/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/stable/home-assistant/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/stable/home-assistant/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/stable/home-assistant/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/stable/home-assistant/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/stable/home-assistant/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/stable/home-assistant/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/stable/home-assistant/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/stable/home-assistant/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/stable/home-assistant/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/stable/home-assistant/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/stable/home-assistant/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/stable/home-assistant/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/stable/home-assistant/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/stable/home-assistant/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/stable/home-assistant/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/stable/home-assistant/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/stable/home-assistant/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/stable/home-assistant/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/stable/home-assistant/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/stable/home-assistant/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/stable/home-assistant/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/stable/home-assistant/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/stable/home-assistant/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/stable/home-assistant/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/stable/home-assistant/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/stable/home-assistant/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/stable/ix-app/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/stable/ix-app/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/stable/ix-app/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/stable/ix-app/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/stable/ix-app/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/stable/ix-app/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/stable/ix-app/templates/library/base_v2_1_9/container.py rename ix-dev/stable/ix-app/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/stable/ix-app/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/stable/ix-app/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/stable/ix-app/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/stable/ix-app/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/stable/ix-app/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/stable/ix-app/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/stable/ix-app/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/stable/ix-app/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/stable/ix-app/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/stable/ix-app/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/stable/ix-app/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/stable/ix-app/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/stable/ix-app/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/stable/ix-app/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/stable/ix-app/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/stable/ix-app/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/stable/ix-app/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/stable/ix-app/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/stable/ix-app/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/stable/ix-app/templates/library/base_v2_1_9/ports.py rename ix-dev/stable/ix-app/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/stable/ix-app/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/stable/ix-app/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/stable/ix-app/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/stable/ix-app/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/stable/ix-app/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/stable/ix-app/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/stable/ix-app/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/stable/ix-app/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/stable/ix-app/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/stable/ix-app/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/stable/ix-app/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/stable/ix-app/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/stable/ix-app/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/stable/ix-app/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/stable/ix-app/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/stable/ix-app/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/stable/ix-app/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/stable/ix-app/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/stable/ix-app/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/stable/ix-app/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/stable/ix-app/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/stable/ix-app/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/stable/ix-app/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/stable/ix-app/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/stable/ix-app/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/stable/ix-app/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/stable/ix-app/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/stable/ix-app/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/stable/ix-app/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/stable/ix-app/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/stable/ix-app/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/stable/ix-app/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/stable/ix-app/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/stable/minio/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/stable/minio/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/stable/minio/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/stable/minio/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/stable/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/stable/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/stable/minio/templates/library/base_v2_1_9/container.py rename ix-dev/stable/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/stable/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/stable/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/stable/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/stable/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/stable/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/stable/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/stable/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/stable/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/stable/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/stable/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/stable/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/stable/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/stable/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/stable/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/stable/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/stable/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/stable/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/stable/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/stable/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/stable/minio/templates/library/base_v2_1_9/ports.py rename ix-dev/stable/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/stable/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/stable/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/stable/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/stable/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/stable/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/stable/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/stable/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/stable/minio/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/stable/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/stable/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/stable/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/stable/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/stable/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/stable/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/stable/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/stable/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/stable/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/stable/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/stable/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/stable/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/stable/minio/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/stable/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/stable/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/stable/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/stable/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/stable/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/stable/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/stable/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/stable/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/stable/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/stable/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/stable/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/stable/minio/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/stable/netdata/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/stable/netdata/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/stable/netdata/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/stable/netdata/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/stable/netdata/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/stable/netdata/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/stable/netdata/templates/library/base_v2_1_9/container.py rename ix-dev/stable/netdata/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/stable/netdata/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/stable/netdata/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/stable/netdata/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/stable/netdata/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/stable/netdata/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/stable/netdata/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/stable/netdata/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/stable/netdata/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/stable/netdata/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/stable/netdata/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/stable/netdata/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/stable/netdata/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/stable/netdata/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/stable/netdata/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/stable/netdata/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/stable/netdata/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/stable/netdata/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/stable/netdata/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/stable/netdata/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/stable/netdata/templates/library/base_v2_1_9/ports.py rename ix-dev/stable/netdata/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/stable/netdata/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/stable/netdata/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/stable/netdata/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/stable/netdata/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/stable/netdata/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/stable/netdata/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/stable/netdata/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/stable/netdata/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/stable/netdata/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/stable/netdata/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/stable/netdata/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/stable/netdata/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/stable/netdata/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/stable/netdata/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/stable/netdata/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/stable/netdata/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/stable/netdata/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/stable/netdata/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/stable/netdata/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/stable/netdata/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/stable/netdata/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/stable/netdata/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/stable/netdata/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/stable/netdata/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/stable/netdata/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/stable/netdata/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/stable/netdata/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/stable/netdata/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/stable/netdata/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/stable/netdata/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/stable/netdata/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/stable/netdata/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/stable/netdata/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/stable/nextcloud/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/stable/nextcloud/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/stable/nextcloud/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/stable/nextcloud/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/stable/nextcloud/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/stable/nextcloud/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/stable/nextcloud/templates/library/base_v2_1_9/container.py rename ix-dev/stable/nextcloud/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/stable/nextcloud/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/stable/nextcloud/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/stable/nextcloud/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/stable/nextcloud/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/stable/nextcloud/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/stable/nextcloud/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/stable/nextcloud/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/stable/nextcloud/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/stable/nextcloud/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/stable/nextcloud/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/stable/nextcloud/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/stable/nextcloud/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/stable/nextcloud/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/stable/nextcloud/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/stable/nextcloud/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/stable/nextcloud/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/stable/nextcloud/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/stable/nextcloud/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/stable/nextcloud/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/stable/nextcloud/templates/library/base_v2_1_9/ports.py rename ix-dev/stable/nextcloud/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/stable/nextcloud/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/stable/nextcloud/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/stable/nextcloud/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/stable/nextcloud/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/stable/nextcloud/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/stable/nextcloud/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/stable/nextcloud/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/stable/nextcloud/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/stable/nextcloud/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/stable/nextcloud/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/stable/nextcloud/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/stable/nextcloud/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/stable/nextcloud/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/stable/nextcloud/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/stable/nextcloud/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/stable/nextcloud/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/stable/nextcloud/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/stable/nextcloud/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/stable/nextcloud/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/stable/nextcloud/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/stable/nextcloud/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/stable/nextcloud/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/stable/nextcloud/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/stable/nextcloud/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/stable/nextcloud/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/stable/nextcloud/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/stable/nextcloud/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/stable/nextcloud/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/stable/nextcloud/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/stable/nextcloud/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/stable/nextcloud/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/stable/nextcloud/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/stable/nextcloud/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/stable/photoprism/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/stable/photoprism/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/stable/photoprism/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/stable/photoprism/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/stable/photoprism/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/stable/photoprism/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/stable/photoprism/templates/library/base_v2_1_9/container.py rename ix-dev/stable/photoprism/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/stable/photoprism/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/stable/photoprism/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/stable/photoprism/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/stable/photoprism/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/stable/photoprism/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/stable/photoprism/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/stable/photoprism/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/stable/photoprism/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/stable/photoprism/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/stable/photoprism/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/stable/photoprism/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/stable/photoprism/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/stable/photoprism/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/stable/photoprism/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/stable/photoprism/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/stable/photoprism/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/stable/photoprism/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/stable/photoprism/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/stable/photoprism/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/stable/photoprism/templates/library/base_v2_1_9/ports.py rename ix-dev/stable/photoprism/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/stable/photoprism/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/stable/photoprism/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/stable/photoprism/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/stable/photoprism/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/stable/photoprism/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/stable/photoprism/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/stable/photoprism/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/stable/photoprism/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/stable/photoprism/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/stable/photoprism/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/stable/photoprism/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/stable/photoprism/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/stable/photoprism/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/stable/photoprism/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/stable/photoprism/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/stable/photoprism/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/stable/photoprism/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/stable/photoprism/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/stable/photoprism/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/stable/photoprism/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/stable/photoprism/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/stable/photoprism/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/stable/photoprism/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/stable/photoprism/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/stable/photoprism/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/stable/photoprism/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/stable/photoprism/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/stable/photoprism/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/stable/photoprism/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/stable/photoprism/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/stable/photoprism/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/stable/photoprism/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/stable/photoprism/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/stable/pihole/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/stable/pihole/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/stable/pihole/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/stable/pihole/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/stable/pihole/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/stable/pihole/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/stable/pihole/templates/library/base_v2_1_9/container.py rename ix-dev/stable/pihole/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/stable/pihole/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/stable/pihole/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/stable/pihole/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/stable/pihole/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/stable/pihole/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/stable/pihole/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/stable/pihole/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/stable/pihole/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/stable/pihole/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/stable/pihole/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/stable/pihole/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/stable/pihole/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/stable/pihole/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/stable/pihole/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/stable/pihole/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/stable/pihole/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/stable/pihole/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/stable/pihole/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/stable/pihole/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/stable/pihole/templates/library/base_v2_1_9/ports.py rename ix-dev/stable/pihole/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/stable/pihole/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/stable/pihole/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/stable/pihole/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/stable/pihole/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/stable/pihole/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/stable/pihole/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/stable/pihole/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/stable/pihole/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/stable/pihole/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/stable/pihole/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/stable/pihole/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/stable/pihole/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/stable/pihole/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/stable/pihole/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/stable/pihole/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/stable/pihole/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/stable/pihole/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/stable/pihole/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/stable/pihole/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/stable/pihole/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/stable/pihole/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/stable/pihole/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/stable/pihole/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/stable/pihole/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/stable/pihole/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/stable/pihole/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/stable/pihole/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/stable/pihole/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/stable/pihole/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/stable/pihole/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/stable/pihole/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/stable/pihole/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/stable/pihole/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/stable/plex/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/stable/plex/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/stable/plex/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/stable/plex/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/stable/plex/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/stable/plex/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/stable/plex/templates/library/base_v2_1_9/container.py rename ix-dev/stable/plex/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/stable/plex/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/stable/plex/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/stable/plex/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/stable/plex/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/stable/plex/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/stable/plex/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/stable/plex/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/stable/plex/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/stable/plex/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/stable/plex/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/stable/plex/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/stable/plex/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/stable/plex/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/stable/plex/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/stable/plex/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/stable/plex/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/stable/plex/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/stable/plex/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/stable/plex/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/stable/plex/templates/library/base_v2_1_9/ports.py rename ix-dev/stable/plex/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/stable/plex/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/stable/plex/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/stable/plex/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/stable/plex/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/stable/plex/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/stable/plex/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/stable/plex/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/stable/plex/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/stable/plex/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/stable/plex/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/stable/plex/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/stable/plex/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/stable/plex/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/stable/plex/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/stable/plex/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/stable/plex/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/stable/plex/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/stable/plex/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/stable/plex/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/stable/plex/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/stable/plex/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/stable/plex/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/stable/plex/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/stable/plex/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/stable/plex/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/stable/plex/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/stable/plex/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/stable/plex/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/stable/plex/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/stable/plex/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/stable/plex/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/stable/plex/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/stable/plex/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/stable/prometheus/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/stable/prometheus/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/stable/prometheus/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/stable/prometheus/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/stable/prometheus/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/stable/prometheus/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/stable/prometheus/templates/library/base_v2_1_9/container.py rename ix-dev/stable/prometheus/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/stable/prometheus/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/stable/prometheus/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/stable/prometheus/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/stable/prometheus/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/stable/prometheus/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/stable/prometheus/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/stable/prometheus/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/stable/prometheus/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/stable/prometheus/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/stable/prometheus/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/stable/prometheus/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/stable/prometheus/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/stable/prometheus/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/stable/prometheus/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/stable/prometheus/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/stable/prometheus/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/stable/prometheus/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/stable/prometheus/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/stable/prometheus/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/stable/prometheus/templates/library/base_v2_1_9/ports.py rename ix-dev/stable/prometheus/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/stable/prometheus/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/stable/prometheus/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/stable/prometheus/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/stable/prometheus/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/stable/prometheus/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/stable/prometheus/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/stable/prometheus/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/stable/prometheus/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/stable/prometheus/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/stable/prometheus/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/stable/prometheus/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/stable/prometheus/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/stable/prometheus/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/stable/prometheus/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/stable/prometheus/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/stable/prometheus/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/stable/prometheus/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/stable/prometheus/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/stable/prometheus/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/stable/prometheus/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/stable/prometheus/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/stable/prometheus/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/stable/prometheus/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/stable/prometheus/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/stable/prometheus/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/stable/prometheus/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/stable/prometheus/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/stable/prometheus/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/stable/prometheus/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/stable/prometheus/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/stable/prometheus/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/stable/prometheus/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/stable/prometheus/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/stable/storj/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/stable/storj/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/stable/storj/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/stable/storj/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/stable/storj/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/stable/storj/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/stable/storj/templates/library/base_v2_1_9/container.py rename ix-dev/stable/storj/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/stable/storj/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/stable/storj/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/stable/storj/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/stable/storj/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/stable/storj/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/stable/storj/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/stable/storj/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/stable/storj/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/stable/storj/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/stable/storj/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/stable/storj/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/stable/storj/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/stable/storj/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/stable/storj/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/stable/storj/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/stable/storj/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/stable/storj/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/stable/storj/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/stable/storj/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/stable/storj/templates/library/base_v2_1_9/ports.py rename ix-dev/stable/storj/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/stable/storj/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/stable/storj/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/stable/storj/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/stable/storj/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/stable/storj/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/stable/storj/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/stable/storj/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/stable/storj/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/stable/storj/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/stable/storj/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/stable/storj/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/stable/storj/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/stable/storj/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/stable/storj/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/stable/storj/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/stable/storj/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/stable/storj/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/stable/storj/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/stable/storj/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/stable/storj/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/stable/storj/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/stable/storj/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/stable/storj/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/stable/storj/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/stable/storj/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/stable/storj/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/stable/storj/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/stable/storj/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/stable/storj/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/stable/storj/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/stable/storj/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/stable/storj/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/stable/storj/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/stable/syncthing/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/stable/syncthing/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/stable/syncthing/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/stable/syncthing/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/stable/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/stable/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/stable/syncthing/templates/library/base_v2_1_9/container.py rename ix-dev/stable/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/stable/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/stable/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/stable/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/stable/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/stable/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/stable/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/stable/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/stable/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/stable/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/stable/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/stable/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/stable/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/stable/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/stable/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/stable/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/stable/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/stable/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/stable/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/stable/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/stable/syncthing/templates/library/base_v2_1_9/ports.py rename ix-dev/stable/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/stable/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/stable/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/stable/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/stable/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/stable/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/stable/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/stable/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/stable/syncthing/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/stable/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/stable/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/stable/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/stable/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/stable/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/stable/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/stable/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/stable/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/stable/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/stable/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/stable/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/stable/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/stable/syncthing/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/stable/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/stable/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/stable/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/stable/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/stable/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/stable/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/stable/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/stable/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/stable/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/stable/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/stable/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/stable/syncthing/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/stable/wg-easy/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/stable/wg-easy/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/stable/wg-easy/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/stable/wg-easy/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/stable/wg-easy/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/stable/wg-easy/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/stable/wg-easy/templates/library/base_v2_1_9/container.py rename ix-dev/stable/wg-easy/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/stable/wg-easy/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/stable/wg-easy/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/stable/wg-easy/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/stable/wg-easy/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/stable/wg-easy/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/stable/wg-easy/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/stable/wg-easy/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/stable/wg-easy/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/stable/wg-easy/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/stable/wg-easy/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/stable/wg-easy/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/stable/wg-easy/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/stable/wg-easy/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/stable/wg-easy/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/stable/wg-easy/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/stable/wg-easy/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/stable/wg-easy/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/stable/wg-easy/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/stable/wg-easy/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/stable/wg-easy/templates/library/base_v2_1_9/ports.py rename ix-dev/stable/wg-easy/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/stable/wg-easy/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/stable/wg-easy/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/stable/wg-easy/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/stable/wg-easy/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/stable/wg-easy/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/stable/wg-easy/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/stable/wg-easy/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/stable/wg-easy/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/stable/wg-easy/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/stable/wg-easy/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/stable/wg-easy/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/stable/wg-easy/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/stable/wg-easy/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/stable/wg-easy/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/stable/wg-easy/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/stable/wg-easy/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/stable/wg-easy/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/stable/wg-easy/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/stable/wg-easy/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/stable/wg-easy/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/stable/wg-easy/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/stable/wg-easy/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/stable/wg-easy/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/stable/wg-easy/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/stable/wg-easy/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/stable/wg-easy/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/stable/wg-easy/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/stable/wg-easy/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/stable/wg-easy/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/stable/wg-easy/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/stable/wg-easy/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/stable/wg-easy/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/stable/wg-easy/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) delete mode 100644 ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/container.py delete mode 100644 ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/ports.py delete mode 100644 ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/tests/test_container.py delete mode 100644 ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/tests/test_ports.py rename ix-dev/test/ix-remote-assist/templates/library/{base_v2_1_8 => base_v2_1_9}/__init__.py (100%) rename ix-dev/test/ix-remote-assist/templates/library/{base_v2_1_8 => base_v2_1_9}/configs.py (100%) create mode 100644 ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/container.py rename ix-dev/test/ix-remote-assist/templates/library/{base_v2_1_8 => base_v2_1_9}/depends.py (100%) rename ix-dev/test/ix-remote-assist/templates/library/{base_v2_1_8 => base_v2_1_9}/deploy.py (100%) rename ix-dev/test/ix-remote-assist/templates/library/{base_v2_1_8 => base_v2_1_9}/deps.py (100%) rename ix-dev/test/ix-remote-assist/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_mariadb.py (100%) rename ix-dev/test/ix-remote-assist/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_perms.py (100%) rename ix-dev/test/ix-remote-assist/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_postgres.py (100%) rename ix-dev/test/ix-remote-assist/templates/library/{base_v2_1_8 => base_v2_1_9}/deps_redis.py (100%) rename ix-dev/test/ix-remote-assist/templates/library/{base_v2_1_8 => base_v2_1_9}/device.py (100%) rename ix-dev/test/ix-remote-assist/templates/library/{base_v2_1_8 => base_v2_1_9}/devices.py (100%) rename ix-dev/test/ix-remote-assist/templates/library/{base_v2_1_8 => base_v2_1_9}/dns.py (100%) rename ix-dev/test/ix-remote-assist/templates/library/{base_v2_1_8 => base_v2_1_9}/environment.py (100%) rename ix-dev/test/ix-remote-assist/templates/library/{base_v2_1_8 => base_v2_1_9}/error.py (100%) rename ix-dev/test/ix-remote-assist/templates/library/{base_v2_1_8 => base_v2_1_9}/expose.py (100%) rename ix-dev/test/ix-remote-assist/templates/library/{base_v2_1_8 => base_v2_1_9}/formatter.py (100%) rename ix-dev/test/ix-remote-assist/templates/library/{base_v2_1_8 => base_v2_1_9}/functions.py (100%) rename ix-dev/test/ix-remote-assist/templates/library/{base_v2_1_8 => base_v2_1_9}/healthcheck.py (100%) rename ix-dev/test/ix-remote-assist/templates/library/{base_v2_1_8 => base_v2_1_9}/labels.py (100%) rename ix-dev/test/ix-remote-assist/templates/library/{base_v2_1_8 => base_v2_1_9}/notes.py (100%) rename ix-dev/test/ix-remote-assist/templates/library/{base_v2_1_8 => base_v2_1_9}/portal.py (100%) rename ix-dev/test/ix-remote-assist/templates/library/{base_v2_1_8 => base_v2_1_9}/portals.py (100%) create mode 100644 ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/ports.py rename ix-dev/test/ix-remote-assist/templates/library/{base_v2_1_8 => base_v2_1_9}/render.py (100%) rename ix-dev/test/ix-remote-assist/templates/library/{base_v2_1_8 => base_v2_1_9}/resources.py (100%) rename ix-dev/test/ix-remote-assist/templates/library/{base_v2_1_8 => base_v2_1_9}/restart.py (100%) rename ix-dev/test/ix-remote-assist/templates/library/{base_v2_1_8 => base_v2_1_9}/storage.py (100%) rename ix-dev/test/ix-remote-assist/templates/library/{base_v2_1_8 => base_v2_1_9}/sysctls.py (100%) rename ix-dev/test/ix-remote-assist/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/__init__.py (100%) rename ix-dev/test/ix-remote-assist/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_build_image.py (100%) rename ix-dev/test/ix-remote-assist/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_configs.py (100%) create mode 100644 ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/tests/test_container.py rename ix-dev/test/ix-remote-assist/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_depends.py (100%) rename ix-dev/test/ix-remote-assist/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_deps.py (100%) rename ix-dev/test/ix-remote-assist/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_device.py (100%) rename ix-dev/test/ix-remote-assist/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_dns.py (100%) rename ix-dev/test/ix-remote-assist/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_environment.py (100%) rename ix-dev/test/ix-remote-assist/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_expose.py (100%) rename ix-dev/test/ix-remote-assist/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_formatter.py (100%) rename ix-dev/test/ix-remote-assist/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_functions.py (100%) rename ix-dev/test/ix-remote-assist/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_healthcheck.py (100%) rename ix-dev/test/ix-remote-assist/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_labels.py (100%) rename ix-dev/test/ix-remote-assist/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_notes.py (100%) rename ix-dev/test/ix-remote-assist/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_portal.py (100%) create mode 100644 ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/tests/test_ports.py rename ix-dev/test/ix-remote-assist/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_render.py (100%) rename ix-dev/test/ix-remote-assist/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_resources.py (100%) rename ix-dev/test/ix-remote-assist/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_restart.py (100%) rename ix-dev/test/ix-remote-assist/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_sysctls.py (100%) rename ix-dev/test/ix-remote-assist/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_validations.py (100%) rename ix-dev/test/ix-remote-assist/templates/library/{base_v2_1_8 => base_v2_1_9}/tests/test_volumes.py (100%) rename ix-dev/test/ix-remote-assist/templates/library/{base_v2_1_8 => base_v2_1_9}/validations.py (100%) rename ix-dev/test/ix-remote-assist/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount.py (100%) rename ix-dev/test/ix-remote-assist/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_mount_types.py (100%) rename ix-dev/test/ix-remote-assist/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_sources.py (100%) rename ix-dev/test/ix-remote-assist/templates/library/{base_v2_1_8 => base_v2_1_9}/volume_types.py (100%) rename ix-dev/test/ix-remote-assist/templates/library/{base_v2_1_8 => base_v2_1_9}/volumes.py (100%) diff --git a/ix-dev/community/actual-budget/app.yaml b/ix-dev/community/actual-budget/app.yaml index dce552459c..33638eb9b9 100644 --- a/ix-dev/community/actual-budget/app.yaml +++ b/ix-dev/community/actual-budget/app.yaml @@ -10,8 +10,8 @@ icon: https://media.sys.truenas.net/apps/actual-budget/icons/icon.png keywords: - finance - budget -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -32,4 +32,4 @@ sources: - https://hub.docker.com/r/actualbudget/actual-server title: Actual Budget train: community -version: 1.2.9 +version: 1.2.10 diff --git a/ix-dev/community/actual-budget/templates/library/base_v2_1_8/ports.py b/ix-dev/community/actual-budget/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/actual-budget/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/actual-budget/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/actual-budget/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/actual-budget/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/actual-budget/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/actual-budget/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/actual-budget/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/actual-budget/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/actual-budget/templates/library/base_v2_1_8/configs.py b/ix-dev/community/actual-budget/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/actual-budget/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/actual-budget/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/actual-budget/templates/library/base_v2_1_8/container.py b/ix-dev/community/actual-budget/templates/library/base_v2_1_9/container.py similarity index 97% rename from ix-dev/community/actual-budget/templates/library/base_v2_1_8/container.py rename to ix-dev/community/actual-budget/templates/library/base_v2_1_9/container.py index 3e787abc5f..d0abd6edc7 100644 --- a/ix-dev/community/actual-budget/templates/library/base_v2_1_8/container.py +++ b/ix-dev/community/actual-budget/templates/library/base_v2_1_9/container.py @@ -235,10 +235,13 @@ def add_port(self, port_config: dict | None = None, dev_config: dict | None = No host_port = config.get("port_number", 0) container_port = config.get("container_port", 0) or host_port protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) elif bind_mode == "exposed": self.expose.add_port(container_port, protocol) diff --git a/ix-dev/community/actual-budget/templates/library/base_v2_1_8/depends.py b/ix-dev/community/actual-budget/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/actual-budget/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/actual-budget/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/actual-budget/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/actual-budget/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/actual-budget/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/actual-budget/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/actual-budget/templates/library/base_v2_1_8/deps.py b/ix-dev/community/actual-budget/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/actual-budget/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/actual-budget/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/actual-budget/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/actual-budget/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/actual-budget/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/actual-budget/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/actual-budget/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/actual-budget/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/actual-budget/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/actual-budget/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/actual-budget/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/actual-budget/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/actual-budget/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/actual-budget/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/actual-budget/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/actual-budget/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/actual-budget/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/actual-budget/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/actual-budget/templates/library/base_v2_1_8/device.py b/ix-dev/community/actual-budget/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/actual-budget/templates/library/base_v2_1_8/device.py rename to ix-dev/community/actual-budget/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/actual-budget/templates/library/base_v2_1_8/devices.py b/ix-dev/community/actual-budget/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/actual-budget/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/actual-budget/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/actual-budget/templates/library/base_v2_1_8/dns.py b/ix-dev/community/actual-budget/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/actual-budget/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/actual-budget/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/actual-budget/templates/library/base_v2_1_8/environment.py b/ix-dev/community/actual-budget/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/actual-budget/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/actual-budget/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/actual-budget/templates/library/base_v2_1_8/error.py b/ix-dev/community/actual-budget/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/actual-budget/templates/library/base_v2_1_8/error.py rename to ix-dev/community/actual-budget/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/actual-budget/templates/library/base_v2_1_8/expose.py b/ix-dev/community/actual-budget/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/actual-budget/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/actual-budget/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/actual-budget/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/actual-budget/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/actual-budget/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/actual-budget/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/actual-budget/templates/library/base_v2_1_8/functions.py b/ix-dev/community/actual-budget/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/actual-budget/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/actual-budget/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/actual-budget/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/actual-budget/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/actual-budget/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/actual-budget/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/actual-budget/templates/library/base_v2_1_8/labels.py b/ix-dev/community/actual-budget/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/actual-budget/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/actual-budget/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/actual-budget/templates/library/base_v2_1_8/notes.py b/ix-dev/community/actual-budget/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/actual-budget/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/actual-budget/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/actual-budget/templates/library/base_v2_1_8/portal.py b/ix-dev/community/actual-budget/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/actual-budget/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/actual-budget/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/actual-budget/templates/library/base_v2_1_8/portals.py b/ix-dev/community/actual-budget/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/actual-budget/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/actual-budget/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/actual-budget/templates/library/base_v2_1_9/ports.py b/ix-dev/community/actual-budget/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/actual-budget/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/actual-budget/templates/library/base_v2_1_8/render.py b/ix-dev/community/actual-budget/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/actual-budget/templates/library/base_v2_1_8/render.py rename to ix-dev/community/actual-budget/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/actual-budget/templates/library/base_v2_1_8/resources.py b/ix-dev/community/actual-budget/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/actual-budget/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/actual-budget/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/actual-budget/templates/library/base_v2_1_8/restart.py b/ix-dev/community/actual-budget/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/actual-budget/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/actual-budget/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/actual-budget/templates/library/base_v2_1_8/storage.py b/ix-dev/community/actual-budget/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/actual-budget/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/actual-budget/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/actual-budget/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/actual-budget/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/actual-budget/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/actual-budget/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/actual-budget/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/actual-budget/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/actual-budget/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/actual-budget/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/actual-budget/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/actual-budget/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/actual-budget/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/actual-budget/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/actual-budget/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/actual-budget/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/actual-budget/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/actual-budget/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/actual-budget/templates/library/base_v2_1_9/tests/test_container.py similarity index 96% rename from ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/tests/test_container.py rename to ix-dev/community/actual-budget/templates/library/base_v2_1_9/tests/test_container.py index 1e3c5c81c3..bb3d98dffc 100644 --- a/ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/tests/test_container.py +++ b/ix-dev/community/actual-budget/templates/library/base_v2_1_9/tests/test_container.py @@ -354,8 +354,16 @@ def test_add_ports(mock_values): ) output = render.render() assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, ] assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/actual-budget/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/actual-budget/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/actual-budget/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/actual-budget/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/actual-budget/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/actual-budget/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/actual-budget/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/actual-budget/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/actual-budget/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/actual-budget/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/actual-budget/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/actual-budget/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/actual-budget/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/actual-budget/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/actual-budget/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/actual-budget/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/actual-budget/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/actual-budget/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/actual-budget/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/actual-budget/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/actual-budget/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/actual-budget/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/actual-budget/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/actual-budget/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/actual-budget/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/actual-budget/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/actual-budget/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/actual-budget/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/actual-budget/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/actual-budget/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/actual-budget/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/actual-budget/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/actual-budget/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/actual-budget/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/actual-budget/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/actual-budget/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/actual-budget/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/actual-budget/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/actual-budget/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/actual-budget/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/actual-budget/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/actual-budget/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/actual-budget/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/actual-budget/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/actual-budget/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/actual-budget/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/actual-budget/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/actual-budget/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/actual-budget/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/actual-budget/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/actual-budget/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/actual-budget/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/actual-budget/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/actual-budget/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/actual-budget/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/actual-budget/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/actual-budget/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/actual-budget/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/actual-budget/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/actual-budget/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/actual-budget/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/actual-budget/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/actual-budget/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/actual-budget/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/actual-budget/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/actual-budget/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/actual-budget/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/actual-budget/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/actual-budget/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/actual-budget/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/actual-budget/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/actual-budget/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/actual-budget/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/actual-budget/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/actual-budget/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/actual-budget/templates/library/base_v2_1_8/validations.py b/ix-dev/community/actual-budget/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/actual-budget/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/actual-budget/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/actual-budget/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/actual-budget/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/actual-budget/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/actual-budget/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/actual-budget/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/actual-budget/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/actual-budget/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/actual-budget/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/actual-budget/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/actual-budget/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/actual-budget/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/actual-budget/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/actual-budget/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/actual-budget/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/actual-budget/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/actual-budget/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/actual-budget/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/actual-budget/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/actual-budget/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/actual-budget/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/adguard-home/app.yaml b/ix-dev/community/adguard-home/app.yaml index 0feccffa9d..b49e27a5cc 100644 --- a/ix-dev/community/adguard-home/app.yaml +++ b/ix-dev/community/adguard-home/app.yaml @@ -20,8 +20,8 @@ icon: https://media.sys.truenas.net/apps/adguard-home/icons/icon.svg keywords: - dns - adblock -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -42,4 +42,4 @@ sources: - https://hub.docker.com/r/adguard/adguardhome title: AdGuard Home train: community -version: 1.1.11 +version: 1.1.12 diff --git a/ix-dev/community/adguard-home/templates/library/base_v2_1_8/ports.py b/ix-dev/community/adguard-home/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/adguard-home/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/adguard-home/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/adguard-home/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/adguard-home/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/adguard-home/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/adguard-home/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/adguard-home/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/adguard-home/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/adguard-home/templates/library/base_v2_1_8/configs.py b/ix-dev/community/adguard-home/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/adguard-home/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/adguard-home/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/adguard-home/templates/library/base_v2_1_8/container.py b/ix-dev/community/adguard-home/templates/library/base_v2_1_9/container.py similarity index 97% rename from ix-dev/community/adguard-home/templates/library/base_v2_1_8/container.py rename to ix-dev/community/adguard-home/templates/library/base_v2_1_9/container.py index 3e787abc5f..d0abd6edc7 100644 --- a/ix-dev/community/adguard-home/templates/library/base_v2_1_8/container.py +++ b/ix-dev/community/adguard-home/templates/library/base_v2_1_9/container.py @@ -235,10 +235,13 @@ def add_port(self, port_config: dict | None = None, dev_config: dict | None = No host_port = config.get("port_number", 0) container_port = config.get("container_port", 0) or host_port protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) elif bind_mode == "exposed": self.expose.add_port(container_port, protocol) diff --git a/ix-dev/community/adguard-home/templates/library/base_v2_1_8/depends.py b/ix-dev/community/adguard-home/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/adguard-home/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/adguard-home/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/adguard-home/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/adguard-home/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/adguard-home/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/adguard-home/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/adguard-home/templates/library/base_v2_1_8/deps.py b/ix-dev/community/adguard-home/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/adguard-home/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/adguard-home/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/adguard-home/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/adguard-home/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/adguard-home/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/adguard-home/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/adguard-home/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/adguard-home/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/adguard-home/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/adguard-home/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/adguard-home/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/adguard-home/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/adguard-home/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/adguard-home/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/adguard-home/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/adguard-home/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/adguard-home/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/adguard-home/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/adguard-home/templates/library/base_v2_1_8/device.py b/ix-dev/community/adguard-home/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/adguard-home/templates/library/base_v2_1_8/device.py rename to ix-dev/community/adguard-home/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/adguard-home/templates/library/base_v2_1_8/devices.py b/ix-dev/community/adguard-home/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/adguard-home/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/adguard-home/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/adguard-home/templates/library/base_v2_1_8/dns.py b/ix-dev/community/adguard-home/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/adguard-home/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/adguard-home/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/adguard-home/templates/library/base_v2_1_8/environment.py b/ix-dev/community/adguard-home/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/adguard-home/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/adguard-home/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/adguard-home/templates/library/base_v2_1_8/error.py b/ix-dev/community/adguard-home/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/adguard-home/templates/library/base_v2_1_8/error.py rename to ix-dev/community/adguard-home/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/adguard-home/templates/library/base_v2_1_8/expose.py b/ix-dev/community/adguard-home/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/adguard-home/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/adguard-home/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/adguard-home/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/adguard-home/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/adguard-home/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/adguard-home/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/adguard-home/templates/library/base_v2_1_8/functions.py b/ix-dev/community/adguard-home/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/adguard-home/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/adguard-home/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/adguard-home/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/adguard-home/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/adguard-home/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/adguard-home/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/adguard-home/templates/library/base_v2_1_8/labels.py b/ix-dev/community/adguard-home/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/adguard-home/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/adguard-home/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/adguard-home/templates/library/base_v2_1_8/notes.py b/ix-dev/community/adguard-home/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/adguard-home/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/adguard-home/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/adguard-home/templates/library/base_v2_1_8/portal.py b/ix-dev/community/adguard-home/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/adguard-home/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/adguard-home/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/adguard-home/templates/library/base_v2_1_8/portals.py b/ix-dev/community/adguard-home/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/adguard-home/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/adguard-home/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/adguard-home/templates/library/base_v2_1_9/ports.py b/ix-dev/community/adguard-home/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/adguard-home/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/adguard-home/templates/library/base_v2_1_8/render.py b/ix-dev/community/adguard-home/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/adguard-home/templates/library/base_v2_1_8/render.py rename to ix-dev/community/adguard-home/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/adguard-home/templates/library/base_v2_1_8/resources.py b/ix-dev/community/adguard-home/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/adguard-home/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/adguard-home/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/adguard-home/templates/library/base_v2_1_8/restart.py b/ix-dev/community/adguard-home/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/adguard-home/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/adguard-home/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/adguard-home/templates/library/base_v2_1_8/storage.py b/ix-dev/community/adguard-home/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/adguard-home/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/adguard-home/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/adguard-home/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/adguard-home/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/adguard-home/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/adguard-home/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/adguard-home/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/adguard-home/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/adguard-home/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/adguard-home/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/adguard-home/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/adguard-home/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/adguard-home/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/adguard-home/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/adguard-home/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/adguard-home/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/adguard-home/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/adguard-home/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/autobrr/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/adguard-home/templates/library/base_v2_1_9/tests/test_container.py similarity index 96% rename from ix-dev/community/autobrr/templates/library/base_v2_1_8/tests/test_container.py rename to ix-dev/community/adguard-home/templates/library/base_v2_1_9/tests/test_container.py index 1e3c5c81c3..bb3d98dffc 100644 --- a/ix-dev/community/autobrr/templates/library/base_v2_1_8/tests/test_container.py +++ b/ix-dev/community/adguard-home/templates/library/base_v2_1_9/tests/test_container.py @@ -354,8 +354,16 @@ def test_add_ports(mock_values): ) output = render.render() assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, ] assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/adguard-home/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/adguard-home/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/adguard-home/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/adguard-home/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/adguard-home/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/adguard-home/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/adguard-home/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/adguard-home/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/adguard-home/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/adguard-home/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/adguard-home/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/adguard-home/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/adguard-home/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/adguard-home/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/adguard-home/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/adguard-home/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/adguard-home/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/adguard-home/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/adguard-home/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/adguard-home/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/adguard-home/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/adguard-home/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/adguard-home/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/adguard-home/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/adguard-home/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/adguard-home/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/adguard-home/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/adguard-home/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/adguard-home/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/adguard-home/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/adguard-home/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/adguard-home/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/adguard-home/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/adguard-home/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/adguard-home/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/adguard-home/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/adguard-home/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/adguard-home/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/adguard-home/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/adguard-home/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/adguard-home/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/adguard-home/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/adguard-home/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/adguard-home/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/adguard-home/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/adguard-home/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/adguard-home/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/adguard-home/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/adguard-home/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/adguard-home/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/adguard-home/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/adguard-home/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/adguard-home/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/adguard-home/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/adguard-home/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/adguard-home/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/adguard-home/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/adguard-home/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/adguard-home/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/adguard-home/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/adguard-home/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/adguard-home/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/adguard-home/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/adguard-home/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/adguard-home/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/adguard-home/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/adguard-home/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/adguard-home/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/adguard-home/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/adguard-home/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/adguard-home/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/adguard-home/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/adguard-home/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/adguard-home/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/adguard-home/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/adguard-home/templates/library/base_v2_1_8/validations.py b/ix-dev/community/adguard-home/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/adguard-home/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/adguard-home/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/adguard-home/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/adguard-home/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/adguard-home/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/adguard-home/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/adguard-home/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/adguard-home/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/adguard-home/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/adguard-home/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/adguard-home/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/adguard-home/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/adguard-home/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/adguard-home/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/adguard-home/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/adguard-home/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/adguard-home/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/adguard-home/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/adguard-home/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/adguard-home/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/adguard-home/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/adguard-home/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/audiobookshelf/app.yaml b/ix-dev/community/audiobookshelf/app.yaml index 37b76f94ec..092f090e11 100644 --- a/ix-dev/community/audiobookshelf/app.yaml +++ b/ix-dev/community/audiobookshelf/app.yaml @@ -9,8 +9,8 @@ icon: https://media.sys.truenas.net/apps/audiobookshelf/icons/icon.svg keywords: - media - audiobook -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -33,4 +33,4 @@ sources: - https://github.com/advplyr/audiobookshelf title: Audiobookshelf train: community -version: 1.3.10 +version: 1.3.11 diff --git a/ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/ports.py b/ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/configs.py b/ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/container.py b/ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/container.py similarity index 97% rename from ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/container.py rename to ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/container.py index 3e787abc5f..d0abd6edc7 100644 --- a/ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/container.py +++ b/ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/container.py @@ -235,10 +235,13 @@ def add_port(self, port_config: dict | None = None, dev_config: dict | None = No host_port = config.get("port_number", 0) container_port = config.get("container_port", 0) or host_port protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) elif bind_mode == "exposed": self.expose.add_port(container_port, protocol) diff --git a/ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/depends.py b/ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/deps.py b/ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/device.py b/ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/device.py rename to ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/devices.py b/ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/dns.py b/ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/environment.py b/ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/error.py b/ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/error.py rename to ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/expose.py b/ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/functions.py b/ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/labels.py b/ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/notes.py b/ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/portal.py b/ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/portals.py b/ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/ports.py b/ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/render.py b/ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/render.py rename to ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/resources.py b/ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/restart.py b/ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/storage.py b/ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/actual-budget/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/tests/test_container.py similarity index 96% rename from ix-dev/community/actual-budget/templates/library/base_v2_1_8/tests/test_container.py rename to ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/tests/test_container.py index 1e3c5c81c3..bb3d98dffc 100644 --- a/ix-dev/community/actual-budget/templates/library/base_v2_1_8/tests/test_container.py +++ b/ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/tests/test_container.py @@ -354,8 +354,16 @@ def test_add_ports(mock_values): ) output = render.render() assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, ] assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/validations.py b/ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/audiobookshelf/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/audiobookshelf/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/autobrr/app.yaml b/ix-dev/community/autobrr/app.yaml index 3e37578ec9..e1ed5dd294 100644 --- a/ix-dev/community/autobrr/app.yaml +++ b/ix-dev/community/autobrr/app.yaml @@ -10,8 +10,8 @@ keywords: - media - torrent - usenet -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -31,4 +31,4 @@ sources: - https://github.com/autobrr/autobrr title: Autobrr train: community -version: 1.2.13 +version: 1.2.14 diff --git a/ix-dev/community/autobrr/templates/library/base_v2_1_8/ports.py b/ix-dev/community/autobrr/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/autobrr/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/autobrr/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/autobrr/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/autobrr/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/autobrr/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/autobrr/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/autobrr/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/autobrr/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/autobrr/templates/library/base_v2_1_8/configs.py b/ix-dev/community/autobrr/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/autobrr/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/autobrr/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/autobrr/templates/library/base_v2_1_8/container.py b/ix-dev/community/autobrr/templates/library/base_v2_1_9/container.py similarity index 97% rename from ix-dev/community/autobrr/templates/library/base_v2_1_8/container.py rename to ix-dev/community/autobrr/templates/library/base_v2_1_9/container.py index 3e787abc5f..d0abd6edc7 100644 --- a/ix-dev/community/autobrr/templates/library/base_v2_1_8/container.py +++ b/ix-dev/community/autobrr/templates/library/base_v2_1_9/container.py @@ -235,10 +235,13 @@ def add_port(self, port_config: dict | None = None, dev_config: dict | None = No host_port = config.get("port_number", 0) container_port = config.get("container_port", 0) or host_port protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) elif bind_mode == "exposed": self.expose.add_port(container_port, protocol) diff --git a/ix-dev/community/autobrr/templates/library/base_v2_1_8/depends.py b/ix-dev/community/autobrr/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/autobrr/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/autobrr/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/autobrr/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/autobrr/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/autobrr/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/autobrr/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/autobrr/templates/library/base_v2_1_8/deps.py b/ix-dev/community/autobrr/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/autobrr/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/autobrr/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/autobrr/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/autobrr/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/autobrr/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/autobrr/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/autobrr/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/autobrr/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/autobrr/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/autobrr/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/autobrr/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/autobrr/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/autobrr/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/autobrr/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/autobrr/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/autobrr/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/autobrr/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/autobrr/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/autobrr/templates/library/base_v2_1_8/device.py b/ix-dev/community/autobrr/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/autobrr/templates/library/base_v2_1_8/device.py rename to ix-dev/community/autobrr/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/autobrr/templates/library/base_v2_1_8/devices.py b/ix-dev/community/autobrr/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/autobrr/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/autobrr/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/autobrr/templates/library/base_v2_1_8/dns.py b/ix-dev/community/autobrr/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/autobrr/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/autobrr/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/autobrr/templates/library/base_v2_1_8/environment.py b/ix-dev/community/autobrr/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/autobrr/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/autobrr/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/autobrr/templates/library/base_v2_1_8/error.py b/ix-dev/community/autobrr/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/autobrr/templates/library/base_v2_1_8/error.py rename to ix-dev/community/autobrr/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/autobrr/templates/library/base_v2_1_8/expose.py b/ix-dev/community/autobrr/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/autobrr/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/autobrr/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/autobrr/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/autobrr/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/autobrr/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/autobrr/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/autobrr/templates/library/base_v2_1_8/functions.py b/ix-dev/community/autobrr/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/autobrr/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/autobrr/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/autobrr/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/autobrr/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/autobrr/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/autobrr/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/autobrr/templates/library/base_v2_1_8/labels.py b/ix-dev/community/autobrr/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/autobrr/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/autobrr/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/autobrr/templates/library/base_v2_1_8/notes.py b/ix-dev/community/autobrr/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/autobrr/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/autobrr/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/autobrr/templates/library/base_v2_1_8/portal.py b/ix-dev/community/autobrr/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/autobrr/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/autobrr/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/autobrr/templates/library/base_v2_1_8/portals.py b/ix-dev/community/autobrr/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/autobrr/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/autobrr/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/autobrr/templates/library/base_v2_1_9/ports.py b/ix-dev/community/autobrr/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/autobrr/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/autobrr/templates/library/base_v2_1_8/render.py b/ix-dev/community/autobrr/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/autobrr/templates/library/base_v2_1_8/render.py rename to ix-dev/community/autobrr/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/autobrr/templates/library/base_v2_1_8/resources.py b/ix-dev/community/autobrr/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/autobrr/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/autobrr/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/autobrr/templates/library/base_v2_1_8/restart.py b/ix-dev/community/autobrr/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/autobrr/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/autobrr/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/autobrr/templates/library/base_v2_1_8/storage.py b/ix-dev/community/autobrr/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/autobrr/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/autobrr/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/autobrr/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/autobrr/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/autobrr/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/autobrr/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/autobrr/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/autobrr/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/autobrr/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/autobrr/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/autobrr/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/autobrr/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/autobrr/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/autobrr/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/autobrr/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/autobrr/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/autobrr/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/autobrr/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/adguard-home/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/autobrr/templates/library/base_v2_1_9/tests/test_container.py similarity index 96% rename from ix-dev/community/adguard-home/templates/library/base_v2_1_8/tests/test_container.py rename to ix-dev/community/autobrr/templates/library/base_v2_1_9/tests/test_container.py index 1e3c5c81c3..bb3d98dffc 100644 --- a/ix-dev/community/adguard-home/templates/library/base_v2_1_8/tests/test_container.py +++ b/ix-dev/community/autobrr/templates/library/base_v2_1_9/tests/test_container.py @@ -354,8 +354,16 @@ def test_add_ports(mock_values): ) output = render.render() assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, ] assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/autobrr/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/autobrr/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/autobrr/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/autobrr/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/autobrr/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/autobrr/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/autobrr/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/autobrr/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/autobrr/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/autobrr/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/autobrr/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/autobrr/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/autobrr/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/autobrr/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/autobrr/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/autobrr/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/autobrr/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/autobrr/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/autobrr/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/autobrr/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/autobrr/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/autobrr/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/autobrr/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/autobrr/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/autobrr/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/autobrr/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/autobrr/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/autobrr/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/autobrr/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/autobrr/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/autobrr/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/autobrr/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/autobrr/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/autobrr/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/autobrr/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/autobrr/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/autobrr/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/autobrr/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/autobrr/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/autobrr/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/autobrr/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/autobrr/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/autobrr/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/autobrr/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/autobrr/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/autobrr/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/autobrr/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/autobrr/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/autobrr/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/autobrr/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/autobrr/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/autobrr/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/autobrr/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/autobrr/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/autobrr/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/autobrr/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/autobrr/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/autobrr/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/autobrr/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/autobrr/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/autobrr/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/autobrr/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/autobrr/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/autobrr/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/autobrr/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/autobrr/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/autobrr/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/autobrr/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/autobrr/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/autobrr/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/autobrr/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/autobrr/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/autobrr/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/autobrr/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/autobrr/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/autobrr/templates/library/base_v2_1_8/validations.py b/ix-dev/community/autobrr/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/autobrr/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/autobrr/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/autobrr/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/autobrr/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/autobrr/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/autobrr/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/autobrr/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/autobrr/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/autobrr/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/autobrr/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/autobrr/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/autobrr/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/autobrr/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/autobrr/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/autobrr/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/autobrr/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/autobrr/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/autobrr/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/autobrr/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/autobrr/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/autobrr/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/autobrr/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/bazarr/app.yaml b/ix-dev/community/bazarr/app.yaml index 8a73b0cd27..f46dabe7b2 100644 --- a/ix-dev/community/bazarr/app.yaml +++ b/ix-dev/community/bazarr/app.yaml @@ -10,8 +10,8 @@ icon: https://media.sys.truenas.net/apps/bazarr/icons/icon.png keywords: - media - subtitles -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -31,4 +31,4 @@ sources: - https://github.com/morpheus65535/bazarr title: Bazarr train: community -version: 1.1.7 +version: 1.1.8 diff --git a/ix-dev/community/bazarr/templates/library/base_v2_1_8/container.py b/ix-dev/community/bazarr/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/bazarr/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/bazarr/templates/library/base_v2_1_8/ports.py b/ix-dev/community/bazarr/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/bazarr/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/bazarr/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/bazarr/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/bazarr/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/bazarr/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/bazarr/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/bazarr/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/bazarr/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/bazarr/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/bazarr/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/bazarr/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/bazarr/templates/library/base_v2_1_8/configs.py b/ix-dev/community/bazarr/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/bazarr/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/bazarr/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/bazarr/templates/library/base_v2_1_9/container.py b/ix-dev/community/bazarr/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/bazarr/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/bazarr/templates/library/base_v2_1_8/depends.py b/ix-dev/community/bazarr/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/bazarr/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/bazarr/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/bazarr/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/bazarr/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/bazarr/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/bazarr/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/bazarr/templates/library/base_v2_1_8/deps.py b/ix-dev/community/bazarr/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/bazarr/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/bazarr/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/bazarr/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/bazarr/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/bazarr/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/bazarr/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/bazarr/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/bazarr/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/bazarr/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/bazarr/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/bazarr/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/bazarr/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/bazarr/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/bazarr/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/bazarr/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/bazarr/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/bazarr/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/bazarr/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/bazarr/templates/library/base_v2_1_8/device.py b/ix-dev/community/bazarr/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/bazarr/templates/library/base_v2_1_8/device.py rename to ix-dev/community/bazarr/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/bazarr/templates/library/base_v2_1_8/devices.py b/ix-dev/community/bazarr/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/bazarr/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/bazarr/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/bazarr/templates/library/base_v2_1_8/dns.py b/ix-dev/community/bazarr/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/bazarr/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/bazarr/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/bazarr/templates/library/base_v2_1_8/environment.py b/ix-dev/community/bazarr/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/bazarr/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/bazarr/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/bazarr/templates/library/base_v2_1_8/error.py b/ix-dev/community/bazarr/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/bazarr/templates/library/base_v2_1_8/error.py rename to ix-dev/community/bazarr/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/bazarr/templates/library/base_v2_1_8/expose.py b/ix-dev/community/bazarr/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/bazarr/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/bazarr/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/bazarr/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/bazarr/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/bazarr/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/bazarr/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/bazarr/templates/library/base_v2_1_8/functions.py b/ix-dev/community/bazarr/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/bazarr/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/bazarr/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/bazarr/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/bazarr/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/bazarr/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/bazarr/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/bazarr/templates/library/base_v2_1_8/labels.py b/ix-dev/community/bazarr/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/bazarr/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/bazarr/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/bazarr/templates/library/base_v2_1_8/notes.py b/ix-dev/community/bazarr/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/bazarr/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/bazarr/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/bazarr/templates/library/base_v2_1_8/portal.py b/ix-dev/community/bazarr/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/bazarr/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/bazarr/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/bazarr/templates/library/base_v2_1_8/portals.py b/ix-dev/community/bazarr/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/bazarr/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/bazarr/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/bazarr/templates/library/base_v2_1_9/ports.py b/ix-dev/community/bazarr/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/bazarr/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/bazarr/templates/library/base_v2_1_8/render.py b/ix-dev/community/bazarr/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/bazarr/templates/library/base_v2_1_8/render.py rename to ix-dev/community/bazarr/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/bazarr/templates/library/base_v2_1_8/resources.py b/ix-dev/community/bazarr/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/bazarr/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/bazarr/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/bazarr/templates/library/base_v2_1_8/restart.py b/ix-dev/community/bazarr/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/bazarr/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/bazarr/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/bazarr/templates/library/base_v2_1_8/storage.py b/ix-dev/community/bazarr/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/bazarr/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/bazarr/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/bazarr/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/bazarr/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/bazarr/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/bazarr/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/bazarr/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/bazarr/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/bazarr/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/bazarr/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/bazarr/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/bazarr/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/bazarr/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/bazarr/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/bazarr/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/bazarr/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/bazarr/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/bazarr/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/bazarr/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/bazarr/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/bazarr/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/bazarr/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/bazarr/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/bazarr/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/bazarr/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/bazarr/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/bazarr/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/bazarr/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/bazarr/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/bazarr/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/bazarr/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/bazarr/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/bazarr/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/bazarr/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/bazarr/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/bazarr/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/bazarr/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/bazarr/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/bazarr/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/bazarr/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/bazarr/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/bazarr/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/bazarr/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/bazarr/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/bazarr/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/bazarr/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/bazarr/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/bazarr/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/bazarr/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/bazarr/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/bazarr/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/bazarr/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/bazarr/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/bazarr/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/bazarr/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/bazarr/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/bazarr/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/bazarr/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/bazarr/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/bazarr/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/bazarr/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/bazarr/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/bazarr/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/bazarr/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/bazarr/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/bazarr/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/bazarr/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/bazarr/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/bazarr/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/bazarr/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/bazarr/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/bazarr/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/bazarr/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/bazarr/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/bazarr/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/bazarr/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/bazarr/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/bazarr/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/bazarr/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/bazarr/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/bazarr/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/bazarr/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/bazarr/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/bazarr/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/bazarr/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/bazarr/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/bazarr/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/bazarr/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/bazarr/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/bazarr/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/bazarr/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/bazarr/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/bazarr/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/bazarr/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/bazarr/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/bazarr/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/bazarr/templates/library/base_v2_1_8/validations.py b/ix-dev/community/bazarr/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/bazarr/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/bazarr/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/bazarr/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/bazarr/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/bazarr/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/bazarr/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/bazarr/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/bazarr/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/bazarr/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/bazarr/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/bazarr/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/bazarr/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/bazarr/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/bazarr/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/bazarr/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/bazarr/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/bazarr/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/bazarr/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/bazarr/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/bazarr/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/bazarr/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/bazarr/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/briefkasten/app.yaml b/ix-dev/community/briefkasten/app.yaml index f755a1f1b0..76d4333853 100644 --- a/ix-dev/community/briefkasten/app.yaml +++ b/ix-dev/community/briefkasten/app.yaml @@ -8,8 +8,8 @@ host_mounts: [] icon: https://media.sys.truenas.net/apps/briefkasten/icons/icon.svg keywords: - bookmark -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -36,4 +36,4 @@ sources: - https://docs.briefkastenhq.com/ title: Briefkasten train: community -version: 1.2.5 +version: 1.2.6 diff --git a/ix-dev/community/briefkasten/templates/library/base_v2_1_8/container.py b/ix-dev/community/briefkasten/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/briefkasten/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/briefkasten/templates/library/base_v2_1_8/ports.py b/ix-dev/community/briefkasten/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/briefkasten/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/briefkasten/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/briefkasten/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/briefkasten/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/briefkasten/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/briefkasten/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/briefkasten/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/briefkasten/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/briefkasten/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/briefkasten/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/briefkasten/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/briefkasten/templates/library/base_v2_1_8/configs.py b/ix-dev/community/briefkasten/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/briefkasten/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/briefkasten/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/briefkasten/templates/library/base_v2_1_9/container.py b/ix-dev/community/briefkasten/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/briefkasten/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/briefkasten/templates/library/base_v2_1_8/depends.py b/ix-dev/community/briefkasten/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/briefkasten/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/briefkasten/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/briefkasten/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/briefkasten/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/briefkasten/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/briefkasten/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/briefkasten/templates/library/base_v2_1_8/deps.py b/ix-dev/community/briefkasten/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/briefkasten/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/briefkasten/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/briefkasten/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/briefkasten/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/briefkasten/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/briefkasten/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/briefkasten/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/briefkasten/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/briefkasten/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/briefkasten/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/briefkasten/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/briefkasten/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/briefkasten/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/briefkasten/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/briefkasten/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/briefkasten/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/briefkasten/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/briefkasten/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/briefkasten/templates/library/base_v2_1_8/device.py b/ix-dev/community/briefkasten/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/briefkasten/templates/library/base_v2_1_8/device.py rename to ix-dev/community/briefkasten/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/briefkasten/templates/library/base_v2_1_8/devices.py b/ix-dev/community/briefkasten/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/briefkasten/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/briefkasten/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/briefkasten/templates/library/base_v2_1_8/dns.py b/ix-dev/community/briefkasten/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/briefkasten/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/briefkasten/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/briefkasten/templates/library/base_v2_1_8/environment.py b/ix-dev/community/briefkasten/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/briefkasten/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/briefkasten/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/briefkasten/templates/library/base_v2_1_8/error.py b/ix-dev/community/briefkasten/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/briefkasten/templates/library/base_v2_1_8/error.py rename to ix-dev/community/briefkasten/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/briefkasten/templates/library/base_v2_1_8/expose.py b/ix-dev/community/briefkasten/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/briefkasten/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/briefkasten/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/briefkasten/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/briefkasten/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/briefkasten/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/briefkasten/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/briefkasten/templates/library/base_v2_1_8/functions.py b/ix-dev/community/briefkasten/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/briefkasten/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/briefkasten/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/briefkasten/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/briefkasten/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/briefkasten/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/briefkasten/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/briefkasten/templates/library/base_v2_1_8/labels.py b/ix-dev/community/briefkasten/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/briefkasten/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/briefkasten/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/briefkasten/templates/library/base_v2_1_8/notes.py b/ix-dev/community/briefkasten/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/briefkasten/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/briefkasten/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/briefkasten/templates/library/base_v2_1_8/portal.py b/ix-dev/community/briefkasten/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/briefkasten/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/briefkasten/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/briefkasten/templates/library/base_v2_1_8/portals.py b/ix-dev/community/briefkasten/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/briefkasten/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/briefkasten/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/briefkasten/templates/library/base_v2_1_9/ports.py b/ix-dev/community/briefkasten/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/briefkasten/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/briefkasten/templates/library/base_v2_1_8/render.py b/ix-dev/community/briefkasten/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/briefkasten/templates/library/base_v2_1_8/render.py rename to ix-dev/community/briefkasten/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/briefkasten/templates/library/base_v2_1_8/resources.py b/ix-dev/community/briefkasten/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/briefkasten/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/briefkasten/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/briefkasten/templates/library/base_v2_1_8/restart.py b/ix-dev/community/briefkasten/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/briefkasten/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/briefkasten/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/briefkasten/templates/library/base_v2_1_8/storage.py b/ix-dev/community/briefkasten/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/briefkasten/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/briefkasten/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/briefkasten/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/briefkasten/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/briefkasten/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/briefkasten/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/briefkasten/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/briefkasten/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/briefkasten/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/briefkasten/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/briefkasten/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/briefkasten/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/briefkasten/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/briefkasten/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/briefkasten/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/briefkasten/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/briefkasten/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/briefkasten/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/briefkasten/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/briefkasten/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/briefkasten/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/briefkasten/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/briefkasten/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/briefkasten/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/briefkasten/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/briefkasten/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/briefkasten/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/briefkasten/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/briefkasten/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/briefkasten/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/briefkasten/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/briefkasten/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/briefkasten/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/briefkasten/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/briefkasten/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/briefkasten/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/briefkasten/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/briefkasten/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/briefkasten/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/briefkasten/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/briefkasten/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/briefkasten/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/briefkasten/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/briefkasten/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/briefkasten/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/briefkasten/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/briefkasten/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/briefkasten/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/briefkasten/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/briefkasten/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/briefkasten/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/briefkasten/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/briefkasten/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/briefkasten/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/briefkasten/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/briefkasten/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/briefkasten/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/briefkasten/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/briefkasten/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/briefkasten/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/briefkasten/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/briefkasten/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/briefkasten/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/briefkasten/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/briefkasten/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/briefkasten/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/briefkasten/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/briefkasten/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/briefkasten/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/briefkasten/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/briefkasten/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/briefkasten/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/briefkasten/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/briefkasten/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/briefkasten/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/briefkasten/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/briefkasten/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/briefkasten/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/briefkasten/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/briefkasten/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/briefkasten/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/briefkasten/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/briefkasten/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/briefkasten/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/briefkasten/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/briefkasten/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/briefkasten/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/briefkasten/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/briefkasten/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/briefkasten/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/briefkasten/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/briefkasten/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/briefkasten/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/briefkasten/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/briefkasten/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/briefkasten/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/briefkasten/templates/library/base_v2_1_8/validations.py b/ix-dev/community/briefkasten/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/briefkasten/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/briefkasten/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/briefkasten/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/briefkasten/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/briefkasten/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/briefkasten/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/briefkasten/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/briefkasten/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/briefkasten/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/briefkasten/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/briefkasten/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/briefkasten/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/briefkasten/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/briefkasten/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/briefkasten/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/briefkasten/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/briefkasten/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/briefkasten/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/briefkasten/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/briefkasten/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/briefkasten/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/briefkasten/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/calibre-web/app.yaml b/ix-dev/community/calibre-web/app.yaml index be7f87be5f..4080cbcc14 100644 --- a/ix-dev/community/calibre-web/app.yaml +++ b/ix-dev/community/calibre-web/app.yaml @@ -10,8 +10,8 @@ icon: https://media.sys.truenas.net/apps/calibre-web/icons/icon.svg keywords: - media - ebooks -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -30,4 +30,4 @@ sources: - https://github.com/janeczku/calibre-web title: Calibre Web train: community -version: 1.0.8 +version: 1.0.9 diff --git a/ix-dev/community/calibre-web/templates/library/base_v2_1_8/container.py b/ix-dev/community/calibre-web/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/calibre-web/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/calibre-web/templates/library/base_v2_1_8/ports.py b/ix-dev/community/calibre-web/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/calibre-web/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/calibre-web/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/calibre-web/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/calibre-web/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/calibre-web/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/calibre-web/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/calibre-web/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/calibre-web/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/calibre-web/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/calibre-web/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/calibre-web/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/calibre-web/templates/library/base_v2_1_8/configs.py b/ix-dev/community/calibre-web/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/calibre-web/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/calibre-web/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/calibre-web/templates/library/base_v2_1_9/container.py b/ix-dev/community/calibre-web/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/calibre-web/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/calibre-web/templates/library/base_v2_1_8/depends.py b/ix-dev/community/calibre-web/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/calibre-web/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/calibre-web/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/calibre-web/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/calibre-web/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/calibre-web/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/calibre-web/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/calibre-web/templates/library/base_v2_1_8/deps.py b/ix-dev/community/calibre-web/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/calibre-web/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/calibre-web/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/calibre-web/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/calibre-web/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/calibre-web/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/calibre-web/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/calibre-web/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/calibre-web/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/calibre-web/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/calibre-web/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/calibre-web/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/calibre-web/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/calibre-web/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/calibre-web/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/calibre-web/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/calibre-web/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/calibre-web/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/calibre-web/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/calibre-web/templates/library/base_v2_1_8/device.py b/ix-dev/community/calibre-web/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/calibre-web/templates/library/base_v2_1_8/device.py rename to ix-dev/community/calibre-web/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/calibre-web/templates/library/base_v2_1_8/devices.py b/ix-dev/community/calibre-web/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/calibre-web/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/calibre-web/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/calibre-web/templates/library/base_v2_1_8/dns.py b/ix-dev/community/calibre-web/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/calibre-web/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/calibre-web/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/calibre-web/templates/library/base_v2_1_8/environment.py b/ix-dev/community/calibre-web/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/calibre-web/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/calibre-web/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/calibre-web/templates/library/base_v2_1_8/error.py b/ix-dev/community/calibre-web/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/calibre-web/templates/library/base_v2_1_8/error.py rename to ix-dev/community/calibre-web/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/calibre-web/templates/library/base_v2_1_8/expose.py b/ix-dev/community/calibre-web/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/calibre-web/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/calibre-web/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/calibre-web/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/calibre-web/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/calibre-web/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/calibre-web/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/calibre-web/templates/library/base_v2_1_8/functions.py b/ix-dev/community/calibre-web/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/calibre-web/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/calibre-web/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/calibre-web/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/calibre-web/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/calibre-web/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/calibre-web/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/calibre-web/templates/library/base_v2_1_8/labels.py b/ix-dev/community/calibre-web/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/calibre-web/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/calibre-web/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/calibre-web/templates/library/base_v2_1_8/notes.py b/ix-dev/community/calibre-web/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/calibre-web/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/calibre-web/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/calibre-web/templates/library/base_v2_1_8/portal.py b/ix-dev/community/calibre-web/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/calibre-web/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/calibre-web/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/calibre-web/templates/library/base_v2_1_8/portals.py b/ix-dev/community/calibre-web/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/calibre-web/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/calibre-web/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/calibre-web/templates/library/base_v2_1_9/ports.py b/ix-dev/community/calibre-web/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/calibre-web/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/calibre-web/templates/library/base_v2_1_8/render.py b/ix-dev/community/calibre-web/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/calibre-web/templates/library/base_v2_1_8/render.py rename to ix-dev/community/calibre-web/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/calibre-web/templates/library/base_v2_1_8/resources.py b/ix-dev/community/calibre-web/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/calibre-web/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/calibre-web/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/calibre-web/templates/library/base_v2_1_8/restart.py b/ix-dev/community/calibre-web/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/calibre-web/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/calibre-web/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/calibre-web/templates/library/base_v2_1_8/storage.py b/ix-dev/community/calibre-web/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/calibre-web/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/calibre-web/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/calibre-web/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/calibre-web/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/calibre-web/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/calibre-web/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/calibre-web/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/calibre-web/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/calibre-web/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/calibre-web/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/calibre-web/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/calibre-web/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/calibre-web/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/calibre-web/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/calibre-web/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/calibre-web/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/calibre-web/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/calibre-web/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/calibre-web/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/calibre-web/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/calibre-web/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/calibre-web/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/calibre-web/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/calibre-web/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/calibre-web/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/calibre-web/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/calibre-web/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/calibre-web/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/calibre-web/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/calibre-web/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/calibre-web/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/calibre-web/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/calibre-web/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/calibre-web/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/calibre-web/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/calibre-web/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/calibre-web/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/calibre-web/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/calibre-web/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/calibre-web/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/calibre-web/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/calibre-web/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/calibre-web/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/calibre-web/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/calibre-web/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/calibre-web/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/calibre-web/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/calibre-web/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/calibre-web/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/calibre-web/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/calibre-web/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/calibre-web/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/calibre-web/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/calibre-web/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/calibre-web/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/calibre-web/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/calibre-web/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/calibre-web/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/calibre-web/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/calibre-web/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/calibre-web/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/calibre-web/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/calibre-web/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/calibre-web/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/calibre-web/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/calibre-web/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/calibre-web/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/calibre-web/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/calibre-web/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/calibre-web/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/calibre-web/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/calibre-web/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/calibre-web/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/calibre-web/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/calibre-web/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/calibre-web/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/calibre-web/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/calibre-web/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/calibre-web/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/calibre-web/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/calibre-web/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/calibre-web/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/calibre-web/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/calibre-web/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/calibre-web/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/calibre-web/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/calibre-web/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/calibre-web/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/calibre-web/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/calibre-web/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/calibre-web/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/calibre-web/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/calibre-web/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/calibre-web/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/calibre-web/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/calibre-web/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/calibre-web/templates/library/base_v2_1_8/validations.py b/ix-dev/community/calibre-web/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/calibre-web/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/calibre-web/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/calibre-web/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/calibre-web/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/calibre-web/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/calibre-web/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/calibre-web/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/calibre-web/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/calibre-web/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/calibre-web/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/calibre-web/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/calibre-web/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/calibre-web/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/calibre-web/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/calibre-web/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/calibre-web/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/calibre-web/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/calibre-web/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/calibre-web/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/calibre-web/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/calibre-web/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/calibre-web/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/calibre/app.yaml b/ix-dev/community/calibre/app.yaml index 1ffcacb55e..9b5314096a 100644 --- a/ix-dev/community/calibre/app.yaml +++ b/ix-dev/community/calibre/app.yaml @@ -19,8 +19,8 @@ icon: https://media.sys.truenas.net/apps/calibre/icons/icon.png keywords: - media - ebooks -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -45,4 +45,4 @@ sources: - https://calibre-ebook.com/ title: Calibre train: community -version: 1.0.10 +version: 1.0.11 diff --git a/ix-dev/community/calibre/templates/library/base_v2_1_8/container.py b/ix-dev/community/calibre/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/calibre/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/calibre/templates/library/base_v2_1_8/ports.py b/ix-dev/community/calibre/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/calibre/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/calibre/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/calibre/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/calibre/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/calibre/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/calibre/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/calibre/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/calibre/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/calibre/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/calibre/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/calibre/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/calibre/templates/library/base_v2_1_8/configs.py b/ix-dev/community/calibre/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/calibre/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/calibre/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/calibre/templates/library/base_v2_1_9/container.py b/ix-dev/community/calibre/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/calibre/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/calibre/templates/library/base_v2_1_8/depends.py b/ix-dev/community/calibre/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/calibre/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/calibre/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/calibre/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/calibre/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/calibre/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/calibre/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/calibre/templates/library/base_v2_1_8/deps.py b/ix-dev/community/calibre/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/calibre/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/calibre/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/calibre/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/calibre/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/calibre/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/calibre/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/calibre/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/calibre/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/calibre/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/calibre/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/calibre/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/calibre/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/calibre/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/calibre/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/calibre/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/calibre/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/calibre/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/calibre/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/calibre/templates/library/base_v2_1_8/device.py b/ix-dev/community/calibre/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/calibre/templates/library/base_v2_1_8/device.py rename to ix-dev/community/calibre/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/calibre/templates/library/base_v2_1_8/devices.py b/ix-dev/community/calibre/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/calibre/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/calibre/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/calibre/templates/library/base_v2_1_8/dns.py b/ix-dev/community/calibre/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/calibre/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/calibre/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/calibre/templates/library/base_v2_1_8/environment.py b/ix-dev/community/calibre/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/calibre/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/calibre/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/calibre/templates/library/base_v2_1_8/error.py b/ix-dev/community/calibre/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/calibre/templates/library/base_v2_1_8/error.py rename to ix-dev/community/calibre/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/calibre/templates/library/base_v2_1_8/expose.py b/ix-dev/community/calibre/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/calibre/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/calibre/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/calibre/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/calibre/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/calibre/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/calibre/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/calibre/templates/library/base_v2_1_8/functions.py b/ix-dev/community/calibre/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/calibre/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/calibre/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/calibre/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/calibre/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/calibre/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/calibre/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/calibre/templates/library/base_v2_1_8/labels.py b/ix-dev/community/calibre/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/calibre/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/calibre/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/calibre/templates/library/base_v2_1_8/notes.py b/ix-dev/community/calibre/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/calibre/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/calibre/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/calibre/templates/library/base_v2_1_8/portal.py b/ix-dev/community/calibre/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/calibre/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/calibre/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/calibre/templates/library/base_v2_1_8/portals.py b/ix-dev/community/calibre/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/calibre/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/calibre/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/calibre/templates/library/base_v2_1_9/ports.py b/ix-dev/community/calibre/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/calibre/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/calibre/templates/library/base_v2_1_8/render.py b/ix-dev/community/calibre/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/calibre/templates/library/base_v2_1_8/render.py rename to ix-dev/community/calibre/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/calibre/templates/library/base_v2_1_8/resources.py b/ix-dev/community/calibre/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/calibre/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/calibre/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/calibre/templates/library/base_v2_1_8/restart.py b/ix-dev/community/calibre/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/calibre/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/calibre/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/calibre/templates/library/base_v2_1_8/storage.py b/ix-dev/community/calibre/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/calibre/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/calibre/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/calibre/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/calibre/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/calibre/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/calibre/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/calibre/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/calibre/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/calibre/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/calibre/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/calibre/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/calibre/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/calibre/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/calibre/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/calibre/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/calibre/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/calibre/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/calibre/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/calibre/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/calibre/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/calibre/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/calibre/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/calibre/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/calibre/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/calibre/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/calibre/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/calibre/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/calibre/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/calibre/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/calibre/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/calibre/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/calibre/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/calibre/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/calibre/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/calibre/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/calibre/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/calibre/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/calibre/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/calibre/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/calibre/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/calibre/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/calibre/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/calibre/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/calibre/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/calibre/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/calibre/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/calibre/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/calibre/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/calibre/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/calibre/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/calibre/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/calibre/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/calibre/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/calibre/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/calibre/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/calibre/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/calibre/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/calibre/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/calibre/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/calibre/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/calibre/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/calibre/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/calibre/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/calibre/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/calibre/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/calibre/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/calibre/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/calibre/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/calibre/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/calibre/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/calibre/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/calibre/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/calibre/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/calibre/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/calibre/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/calibre/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/calibre/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/calibre/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/calibre/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/calibre/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/calibre/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/calibre/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/calibre/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/calibre/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/calibre/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/calibre/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/calibre/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/calibre/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/calibre/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/calibre/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/calibre/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/calibre/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/calibre/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/calibre/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/calibre/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/calibre/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/calibre/templates/library/base_v2_1_8/validations.py b/ix-dev/community/calibre/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/calibre/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/calibre/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/calibre/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/calibre/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/calibre/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/calibre/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/calibre/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/calibre/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/calibre/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/calibre/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/calibre/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/calibre/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/calibre/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/calibre/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/calibre/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/calibre/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/calibre/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/calibre/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/calibre/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/calibre/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/calibre/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/calibre/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/castopod/app.yaml b/ix-dev/community/castopod/app.yaml index 1b9ce1805c..26f9f3f87a 100644 --- a/ix-dev/community/castopod/app.yaml +++ b/ix-dev/community/castopod/app.yaml @@ -19,8 +19,8 @@ host_mounts: [] icon: https://media.sys.truenas.net/apps/castopod/icons/icon.svg keywords: - podcast -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -49,4 +49,4 @@ sources: - https://code.castopod.org/adaures/castopod title: Castopod train: community -version: 1.1.10 +version: 1.1.11 diff --git a/ix-dev/community/castopod/templates/library/base_v2_1_8/container.py b/ix-dev/community/castopod/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/castopod/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/castopod/templates/library/base_v2_1_8/ports.py b/ix-dev/community/castopod/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/castopod/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/castopod/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/castopod/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/castopod/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/castopod/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/castopod/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/castopod/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/castopod/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/castopod/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/castopod/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/castopod/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/castopod/templates/library/base_v2_1_8/configs.py b/ix-dev/community/castopod/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/castopod/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/castopod/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/castopod/templates/library/base_v2_1_9/container.py b/ix-dev/community/castopod/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/castopod/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/castopod/templates/library/base_v2_1_8/depends.py b/ix-dev/community/castopod/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/castopod/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/castopod/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/castopod/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/castopod/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/castopod/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/castopod/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/castopod/templates/library/base_v2_1_8/deps.py b/ix-dev/community/castopod/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/castopod/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/castopod/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/castopod/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/castopod/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/castopod/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/castopod/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/castopod/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/castopod/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/castopod/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/castopod/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/castopod/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/castopod/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/castopod/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/castopod/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/castopod/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/castopod/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/castopod/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/castopod/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/castopod/templates/library/base_v2_1_8/device.py b/ix-dev/community/castopod/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/castopod/templates/library/base_v2_1_8/device.py rename to ix-dev/community/castopod/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/castopod/templates/library/base_v2_1_8/devices.py b/ix-dev/community/castopod/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/castopod/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/castopod/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/castopod/templates/library/base_v2_1_8/dns.py b/ix-dev/community/castopod/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/castopod/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/castopod/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/castopod/templates/library/base_v2_1_8/environment.py b/ix-dev/community/castopod/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/castopod/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/castopod/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/castopod/templates/library/base_v2_1_8/error.py b/ix-dev/community/castopod/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/castopod/templates/library/base_v2_1_8/error.py rename to ix-dev/community/castopod/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/castopod/templates/library/base_v2_1_8/expose.py b/ix-dev/community/castopod/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/castopod/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/castopod/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/castopod/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/castopod/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/castopod/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/castopod/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/castopod/templates/library/base_v2_1_8/functions.py b/ix-dev/community/castopod/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/castopod/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/castopod/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/castopod/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/castopod/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/castopod/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/castopod/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/castopod/templates/library/base_v2_1_8/labels.py b/ix-dev/community/castopod/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/castopod/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/castopod/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/castopod/templates/library/base_v2_1_8/notes.py b/ix-dev/community/castopod/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/castopod/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/castopod/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/castopod/templates/library/base_v2_1_8/portal.py b/ix-dev/community/castopod/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/castopod/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/castopod/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/castopod/templates/library/base_v2_1_8/portals.py b/ix-dev/community/castopod/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/castopod/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/castopod/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/castopod/templates/library/base_v2_1_9/ports.py b/ix-dev/community/castopod/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/castopod/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/castopod/templates/library/base_v2_1_8/render.py b/ix-dev/community/castopod/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/castopod/templates/library/base_v2_1_8/render.py rename to ix-dev/community/castopod/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/castopod/templates/library/base_v2_1_8/resources.py b/ix-dev/community/castopod/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/castopod/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/castopod/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/castopod/templates/library/base_v2_1_8/restart.py b/ix-dev/community/castopod/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/castopod/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/castopod/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/castopod/templates/library/base_v2_1_8/storage.py b/ix-dev/community/castopod/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/castopod/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/castopod/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/castopod/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/castopod/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/castopod/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/castopod/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/castopod/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/castopod/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/castopod/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/castopod/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/castopod/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/castopod/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/castopod/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/castopod/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/castopod/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/castopod/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/castopod/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/castopod/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/castopod/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/castopod/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/castopod/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/castopod/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/castopod/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/castopod/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/castopod/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/castopod/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/castopod/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/castopod/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/castopod/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/castopod/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/castopod/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/castopod/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/castopod/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/castopod/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/castopod/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/castopod/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/castopod/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/castopod/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/castopod/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/castopod/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/castopod/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/castopod/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/castopod/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/castopod/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/castopod/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/castopod/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/castopod/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/castopod/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/castopod/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/castopod/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/castopod/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/castopod/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/castopod/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/castopod/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/castopod/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/castopod/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/castopod/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/castopod/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/castopod/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/castopod/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/castopod/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/castopod/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/castopod/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/castopod/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/castopod/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/castopod/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/castopod/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/castopod/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/castopod/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/castopod/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/castopod/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/castopod/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/castopod/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/castopod/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/castopod/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/castopod/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/castopod/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/castopod/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/castopod/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/castopod/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/castopod/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/castopod/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/castopod/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/castopod/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/castopod/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/castopod/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/castopod/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/castopod/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/castopod/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/castopod/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/castopod/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/castopod/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/castopod/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/castopod/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/castopod/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/castopod/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/castopod/templates/library/base_v2_1_8/validations.py b/ix-dev/community/castopod/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/castopod/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/castopod/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/castopod/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/castopod/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/castopod/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/castopod/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/castopod/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/castopod/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/castopod/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/castopod/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/castopod/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/castopod/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/castopod/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/castopod/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/castopod/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/castopod/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/castopod/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/castopod/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/castopod/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/castopod/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/castopod/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/castopod/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/chia/app.yaml b/ix-dev/community/chia/app.yaml index bad66b4420..b8f325dfb5 100644 --- a/ix-dev/community/chia/app.yaml +++ b/ix-dev/community/chia/app.yaml @@ -11,8 +11,8 @@ keywords: - blockchain - hard-drive - chia -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -30,4 +30,4 @@ sources: - https://www.chia.net/ title: Chia train: community -version: 1.1.6 +version: 1.1.7 diff --git a/ix-dev/community/chia/templates/library/base_v2_1_8/container.py b/ix-dev/community/chia/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/chia/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/chia/templates/library/base_v2_1_8/ports.py b/ix-dev/community/chia/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/chia/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/chia/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/chia/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/chia/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/chia/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/chia/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/chia/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/chia/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/chia/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/chia/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/chia/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/chia/templates/library/base_v2_1_8/configs.py b/ix-dev/community/chia/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/chia/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/chia/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/chia/templates/library/base_v2_1_9/container.py b/ix-dev/community/chia/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/chia/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/chia/templates/library/base_v2_1_8/depends.py b/ix-dev/community/chia/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/chia/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/chia/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/chia/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/chia/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/chia/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/chia/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/chia/templates/library/base_v2_1_8/deps.py b/ix-dev/community/chia/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/chia/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/chia/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/chia/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/chia/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/chia/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/chia/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/chia/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/chia/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/chia/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/chia/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/chia/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/chia/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/chia/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/chia/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/chia/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/chia/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/chia/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/chia/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/chia/templates/library/base_v2_1_8/device.py b/ix-dev/community/chia/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/chia/templates/library/base_v2_1_8/device.py rename to ix-dev/community/chia/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/chia/templates/library/base_v2_1_8/devices.py b/ix-dev/community/chia/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/chia/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/chia/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/chia/templates/library/base_v2_1_8/dns.py b/ix-dev/community/chia/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/chia/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/chia/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/chia/templates/library/base_v2_1_8/environment.py b/ix-dev/community/chia/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/chia/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/chia/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/chia/templates/library/base_v2_1_8/error.py b/ix-dev/community/chia/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/chia/templates/library/base_v2_1_8/error.py rename to ix-dev/community/chia/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/chia/templates/library/base_v2_1_8/expose.py b/ix-dev/community/chia/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/chia/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/chia/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/chia/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/chia/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/chia/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/chia/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/chia/templates/library/base_v2_1_8/functions.py b/ix-dev/community/chia/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/chia/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/chia/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/chia/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/chia/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/chia/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/chia/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/chia/templates/library/base_v2_1_8/labels.py b/ix-dev/community/chia/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/chia/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/chia/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/chia/templates/library/base_v2_1_8/notes.py b/ix-dev/community/chia/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/chia/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/chia/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/chia/templates/library/base_v2_1_8/portal.py b/ix-dev/community/chia/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/chia/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/chia/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/chia/templates/library/base_v2_1_8/portals.py b/ix-dev/community/chia/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/chia/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/chia/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/chia/templates/library/base_v2_1_9/ports.py b/ix-dev/community/chia/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/chia/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/chia/templates/library/base_v2_1_8/render.py b/ix-dev/community/chia/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/chia/templates/library/base_v2_1_8/render.py rename to ix-dev/community/chia/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/chia/templates/library/base_v2_1_8/resources.py b/ix-dev/community/chia/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/chia/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/chia/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/chia/templates/library/base_v2_1_8/restart.py b/ix-dev/community/chia/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/chia/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/chia/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/chia/templates/library/base_v2_1_8/storage.py b/ix-dev/community/chia/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/chia/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/chia/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/chia/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/chia/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/chia/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/chia/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/chia/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/chia/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/chia/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/chia/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/chia/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/chia/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/chia/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/chia/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/chia/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/chia/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/chia/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/chia/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/chia/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/chia/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/chia/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/chia/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/chia/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/chia/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/chia/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/chia/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/chia/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/chia/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/chia/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/chia/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/chia/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/chia/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/chia/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/chia/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/chia/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/chia/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/chia/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/chia/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/chia/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/chia/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/chia/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/chia/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/chia/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/chia/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/chia/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/chia/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/chia/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/chia/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/chia/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/chia/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/chia/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/chia/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/chia/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/chia/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/chia/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/chia/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/chia/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/chia/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/chia/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/chia/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/chia/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/chia/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/chia/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/chia/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/chia/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/chia/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/chia/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/chia/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/chia/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/chia/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/chia/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/chia/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/chia/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/chia/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/chia/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/chia/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/chia/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/chia/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/chia/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/chia/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/chia/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/chia/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/chia/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/chia/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/chia/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/chia/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/chia/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/chia/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/chia/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/chia/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/chia/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/chia/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/chia/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/chia/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/chia/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/chia/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/chia/templates/library/base_v2_1_8/validations.py b/ix-dev/community/chia/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/chia/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/chia/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/chia/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/chia/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/chia/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/chia/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/chia/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/chia/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/chia/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/chia/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/chia/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/chia/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/chia/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/chia/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/chia/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/chia/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/chia/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/chia/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/chia/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/chia/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/chia/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/chia/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/clamav/app.yaml b/ix-dev/community/clamav/app.yaml index 0914231fb7..2c03d0ed88 100644 --- a/ix-dev/community/clamav/app.yaml +++ b/ix-dev/community/clamav/app.yaml @@ -19,8 +19,8 @@ icon: https://media.sys.truenas.net/apps/clamav/icons/icon.png keywords: - anti-virus - clamav -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -38,4 +38,4 @@ sources: - https://www.clamav.net/ title: ClamAV train: community -version: 1.2.12 +version: 1.2.13 diff --git a/ix-dev/community/clamav/templates/library/base_v2_1_8/container.py b/ix-dev/community/clamav/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/clamav/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/clamav/templates/library/base_v2_1_8/ports.py b/ix-dev/community/clamav/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/clamav/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/clamav/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/clamav/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/clamav/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/clamav/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/clamav/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/clamav/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/clamav/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/clamav/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/clamav/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/clamav/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/clamav/templates/library/base_v2_1_8/configs.py b/ix-dev/community/clamav/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/clamav/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/clamav/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/clamav/templates/library/base_v2_1_9/container.py b/ix-dev/community/clamav/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/clamav/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/clamav/templates/library/base_v2_1_8/depends.py b/ix-dev/community/clamav/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/clamav/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/clamav/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/clamav/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/clamav/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/clamav/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/clamav/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/clamav/templates/library/base_v2_1_8/deps.py b/ix-dev/community/clamav/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/clamav/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/clamav/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/clamav/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/clamav/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/clamav/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/clamav/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/clamav/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/clamav/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/clamav/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/clamav/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/clamav/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/clamav/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/clamav/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/clamav/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/clamav/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/clamav/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/clamav/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/clamav/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/clamav/templates/library/base_v2_1_8/device.py b/ix-dev/community/clamav/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/clamav/templates/library/base_v2_1_8/device.py rename to ix-dev/community/clamav/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/clamav/templates/library/base_v2_1_8/devices.py b/ix-dev/community/clamav/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/clamav/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/clamav/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/clamav/templates/library/base_v2_1_8/dns.py b/ix-dev/community/clamav/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/clamav/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/clamav/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/clamav/templates/library/base_v2_1_8/environment.py b/ix-dev/community/clamav/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/clamav/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/clamav/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/clamav/templates/library/base_v2_1_8/error.py b/ix-dev/community/clamav/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/clamav/templates/library/base_v2_1_8/error.py rename to ix-dev/community/clamav/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/clamav/templates/library/base_v2_1_8/expose.py b/ix-dev/community/clamav/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/clamav/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/clamav/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/clamav/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/clamav/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/clamav/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/clamav/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/clamav/templates/library/base_v2_1_8/functions.py b/ix-dev/community/clamav/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/clamav/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/clamav/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/clamav/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/clamav/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/clamav/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/clamav/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/clamav/templates/library/base_v2_1_8/labels.py b/ix-dev/community/clamav/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/clamav/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/clamav/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/clamav/templates/library/base_v2_1_8/notes.py b/ix-dev/community/clamav/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/clamav/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/clamav/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/clamav/templates/library/base_v2_1_8/portal.py b/ix-dev/community/clamav/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/clamav/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/clamav/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/clamav/templates/library/base_v2_1_8/portals.py b/ix-dev/community/clamav/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/clamav/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/clamav/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/clamav/templates/library/base_v2_1_9/ports.py b/ix-dev/community/clamav/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/clamav/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/clamav/templates/library/base_v2_1_8/render.py b/ix-dev/community/clamav/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/clamav/templates/library/base_v2_1_8/render.py rename to ix-dev/community/clamav/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/clamav/templates/library/base_v2_1_8/resources.py b/ix-dev/community/clamav/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/clamav/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/clamav/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/clamav/templates/library/base_v2_1_8/restart.py b/ix-dev/community/clamav/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/clamav/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/clamav/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/clamav/templates/library/base_v2_1_8/storage.py b/ix-dev/community/clamav/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/clamav/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/clamav/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/clamav/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/clamav/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/clamav/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/clamav/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/clamav/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/clamav/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/clamav/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/clamav/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/clamav/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/clamav/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/clamav/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/clamav/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/clamav/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/clamav/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/clamav/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/clamav/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/clamav/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/clamav/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/clamav/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/clamav/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/clamav/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/clamav/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/clamav/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/clamav/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/clamav/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/clamav/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/clamav/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/clamav/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/clamav/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/clamav/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/clamav/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/clamav/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/clamav/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/clamav/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/clamav/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/clamav/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/clamav/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/clamav/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/clamav/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/clamav/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/clamav/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/clamav/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/clamav/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/clamav/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/clamav/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/clamav/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/clamav/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/clamav/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/clamav/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/clamav/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/clamav/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/clamav/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/clamav/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/clamav/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/clamav/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/clamav/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/clamav/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/clamav/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/clamav/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/clamav/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/clamav/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/clamav/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/clamav/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/clamav/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/clamav/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/clamav/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/clamav/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/clamav/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/clamav/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/clamav/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/clamav/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/clamav/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/clamav/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/clamav/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/clamav/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/clamav/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/clamav/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/clamav/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/clamav/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/clamav/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/clamav/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/clamav/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/clamav/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/clamav/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/clamav/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/clamav/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/clamav/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/clamav/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/clamav/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/clamav/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/clamav/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/clamav/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/clamav/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/clamav/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/clamav/templates/library/base_v2_1_8/validations.py b/ix-dev/community/clamav/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/clamav/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/clamav/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/clamav/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/clamav/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/clamav/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/clamav/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/clamav/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/clamav/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/clamav/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/clamav/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/clamav/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/clamav/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/clamav/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/clamav/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/clamav/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/clamav/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/clamav/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/clamav/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/clamav/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/clamav/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/clamav/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/clamav/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/cloudflared/app.yaml b/ix-dev/community/cloudflared/app.yaml index ada93bd0fc..e77b0c08ed 100644 --- a/ix-dev/community/cloudflared/app.yaml +++ b/ix-dev/community/cloudflared/app.yaml @@ -11,8 +11,8 @@ keywords: - network - cloudflare - tunnel -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -30,4 +30,4 @@ sources: - https://hub.docker.com/r/cloudflare/cloudflared title: Cloudflared train: community -version: 1.2.9 +version: 1.2.10 diff --git a/ix-dev/community/cloudflared/templates/library/base_v2_1_8/container.py b/ix-dev/community/cloudflared/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/cloudflared/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/cloudflared/templates/library/base_v2_1_8/ports.py b/ix-dev/community/cloudflared/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/cloudflared/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/cloudflared/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/cloudflared/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/cloudflared/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/cloudflared/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/cloudflared/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/cloudflared/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/cloudflared/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/cloudflared/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/cloudflared/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/cloudflared/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/cloudflared/templates/library/base_v2_1_8/configs.py b/ix-dev/community/cloudflared/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/cloudflared/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/cloudflared/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/cloudflared/templates/library/base_v2_1_9/container.py b/ix-dev/community/cloudflared/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/cloudflared/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/cloudflared/templates/library/base_v2_1_8/depends.py b/ix-dev/community/cloudflared/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/cloudflared/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/cloudflared/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/cloudflared/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/cloudflared/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/cloudflared/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/cloudflared/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/cloudflared/templates/library/base_v2_1_8/deps.py b/ix-dev/community/cloudflared/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/cloudflared/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/cloudflared/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/cloudflared/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/cloudflared/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/cloudflared/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/cloudflared/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/cloudflared/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/cloudflared/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/cloudflared/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/cloudflared/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/cloudflared/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/cloudflared/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/cloudflared/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/cloudflared/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/cloudflared/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/cloudflared/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/cloudflared/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/cloudflared/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/cloudflared/templates/library/base_v2_1_8/device.py b/ix-dev/community/cloudflared/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/cloudflared/templates/library/base_v2_1_8/device.py rename to ix-dev/community/cloudflared/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/cloudflared/templates/library/base_v2_1_8/devices.py b/ix-dev/community/cloudflared/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/cloudflared/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/cloudflared/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/cloudflared/templates/library/base_v2_1_8/dns.py b/ix-dev/community/cloudflared/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/cloudflared/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/cloudflared/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/cloudflared/templates/library/base_v2_1_8/environment.py b/ix-dev/community/cloudflared/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/cloudflared/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/cloudflared/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/cloudflared/templates/library/base_v2_1_8/error.py b/ix-dev/community/cloudflared/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/cloudflared/templates/library/base_v2_1_8/error.py rename to ix-dev/community/cloudflared/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/cloudflared/templates/library/base_v2_1_8/expose.py b/ix-dev/community/cloudflared/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/cloudflared/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/cloudflared/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/cloudflared/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/cloudflared/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/cloudflared/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/cloudflared/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/cloudflared/templates/library/base_v2_1_8/functions.py b/ix-dev/community/cloudflared/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/cloudflared/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/cloudflared/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/cloudflared/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/cloudflared/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/cloudflared/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/cloudflared/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/cloudflared/templates/library/base_v2_1_8/labels.py b/ix-dev/community/cloudflared/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/cloudflared/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/cloudflared/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/cloudflared/templates/library/base_v2_1_8/notes.py b/ix-dev/community/cloudflared/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/cloudflared/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/cloudflared/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/cloudflared/templates/library/base_v2_1_8/portal.py b/ix-dev/community/cloudflared/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/cloudflared/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/cloudflared/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/cloudflared/templates/library/base_v2_1_8/portals.py b/ix-dev/community/cloudflared/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/cloudflared/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/cloudflared/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/cloudflared/templates/library/base_v2_1_9/ports.py b/ix-dev/community/cloudflared/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/cloudflared/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/cloudflared/templates/library/base_v2_1_8/render.py b/ix-dev/community/cloudflared/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/cloudflared/templates/library/base_v2_1_8/render.py rename to ix-dev/community/cloudflared/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/cloudflared/templates/library/base_v2_1_8/resources.py b/ix-dev/community/cloudflared/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/cloudflared/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/cloudflared/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/cloudflared/templates/library/base_v2_1_8/restart.py b/ix-dev/community/cloudflared/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/cloudflared/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/cloudflared/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/cloudflared/templates/library/base_v2_1_8/storage.py b/ix-dev/community/cloudflared/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/cloudflared/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/cloudflared/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/cloudflared/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/cloudflared/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/cloudflared/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/cloudflared/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/cloudflared/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/cloudflared/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/cloudflared/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/cloudflared/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/cloudflared/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/cloudflared/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/cloudflared/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/cloudflared/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/cloudflared/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/cloudflared/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/cloudflared/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/cloudflared/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/cloudflared/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/cloudflared/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/cloudflared/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/cloudflared/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/cloudflared/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/cloudflared/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/cloudflared/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/cloudflared/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/cloudflared/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/cloudflared/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/cloudflared/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/cloudflared/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/cloudflared/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/cloudflared/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/cloudflared/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/cloudflared/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/cloudflared/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/cloudflared/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/cloudflared/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/cloudflared/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/cloudflared/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/cloudflared/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/cloudflared/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/cloudflared/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/cloudflared/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/cloudflared/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/cloudflared/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/cloudflared/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/cloudflared/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/cloudflared/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/cloudflared/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/cloudflared/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/cloudflared/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/cloudflared/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/cloudflared/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/cloudflared/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/cloudflared/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/cloudflared/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/cloudflared/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/cloudflared/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/cloudflared/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/cloudflared/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/cloudflared/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/cloudflared/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/cloudflared/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/cloudflared/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/cloudflared/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/cloudflared/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/cloudflared/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/cloudflared/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/cloudflared/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/cloudflared/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/cloudflared/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/cloudflared/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/cloudflared/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/cloudflared/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/cloudflared/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/cloudflared/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/cloudflared/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/cloudflared/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/cloudflared/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/cloudflared/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/cloudflared/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/cloudflared/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/cloudflared/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/cloudflared/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/cloudflared/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/cloudflared/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/cloudflared/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/cloudflared/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/cloudflared/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/cloudflared/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/cloudflared/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/cloudflared/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/cloudflared/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/cloudflared/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/cloudflared/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/cloudflared/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/cloudflared/templates/library/base_v2_1_8/validations.py b/ix-dev/community/cloudflared/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/cloudflared/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/cloudflared/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/cloudflared/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/cloudflared/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/cloudflared/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/cloudflared/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/cloudflared/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/cloudflared/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/cloudflared/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/cloudflared/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/cloudflared/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/cloudflared/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/cloudflared/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/cloudflared/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/cloudflared/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/cloudflared/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/cloudflared/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/cloudflared/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/cloudflared/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/cloudflared/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/cloudflared/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/cloudflared/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/dashy/app.yaml b/ix-dev/community/dashy/app.yaml index b381a3601b..00faedffbe 100644 --- a/ix-dev/community/dashy/app.yaml +++ b/ix-dev/community/dashy/app.yaml @@ -8,8 +8,8 @@ host_mounts: [] icon: https://media.sys.truenas.net/apps/dashy/icons/icon.png keywords: - dashboard -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -29,4 +29,4 @@ sources: - https://github.com/lissy93/dashy title: Dashy train: community -version: 1.1.5 +version: 1.1.6 diff --git a/ix-dev/community/dashy/templates/library/base_v2_1_8/container.py b/ix-dev/community/dashy/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/dashy/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/dashy/templates/library/base_v2_1_8/ports.py b/ix-dev/community/dashy/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/dashy/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/dashy/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/dashy/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/dashy/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/dashy/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/dashy/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/dashy/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/dashy/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/dashy/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/dashy/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/dashy/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/dashy/templates/library/base_v2_1_8/configs.py b/ix-dev/community/dashy/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/dashy/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/dashy/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/dashy/templates/library/base_v2_1_9/container.py b/ix-dev/community/dashy/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/dashy/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/dashy/templates/library/base_v2_1_8/depends.py b/ix-dev/community/dashy/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/dashy/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/dashy/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/dashy/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/dashy/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/dashy/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/dashy/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/dashy/templates/library/base_v2_1_8/deps.py b/ix-dev/community/dashy/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/dashy/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/dashy/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/dashy/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/dashy/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/dashy/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/dashy/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/dashy/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/dashy/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/dashy/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/dashy/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/dashy/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/dashy/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/dashy/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/dashy/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/dashy/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/dashy/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/dashy/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/dashy/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/dashy/templates/library/base_v2_1_8/device.py b/ix-dev/community/dashy/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/dashy/templates/library/base_v2_1_8/device.py rename to ix-dev/community/dashy/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/dashy/templates/library/base_v2_1_8/devices.py b/ix-dev/community/dashy/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/dashy/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/dashy/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/dashy/templates/library/base_v2_1_8/dns.py b/ix-dev/community/dashy/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/dashy/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/dashy/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/dashy/templates/library/base_v2_1_8/environment.py b/ix-dev/community/dashy/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/dashy/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/dashy/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/dashy/templates/library/base_v2_1_8/error.py b/ix-dev/community/dashy/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/dashy/templates/library/base_v2_1_8/error.py rename to ix-dev/community/dashy/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/dashy/templates/library/base_v2_1_8/expose.py b/ix-dev/community/dashy/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/dashy/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/dashy/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/dashy/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/dashy/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/dashy/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/dashy/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/dashy/templates/library/base_v2_1_8/functions.py b/ix-dev/community/dashy/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/dashy/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/dashy/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/dashy/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/dashy/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/dashy/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/dashy/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/dashy/templates/library/base_v2_1_8/labels.py b/ix-dev/community/dashy/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/dashy/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/dashy/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/dashy/templates/library/base_v2_1_8/notes.py b/ix-dev/community/dashy/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/dashy/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/dashy/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/dashy/templates/library/base_v2_1_8/portal.py b/ix-dev/community/dashy/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/dashy/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/dashy/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/dashy/templates/library/base_v2_1_8/portals.py b/ix-dev/community/dashy/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/dashy/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/dashy/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/dashy/templates/library/base_v2_1_9/ports.py b/ix-dev/community/dashy/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/dashy/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/dashy/templates/library/base_v2_1_8/render.py b/ix-dev/community/dashy/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/dashy/templates/library/base_v2_1_8/render.py rename to ix-dev/community/dashy/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/dashy/templates/library/base_v2_1_8/resources.py b/ix-dev/community/dashy/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/dashy/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/dashy/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/dashy/templates/library/base_v2_1_8/restart.py b/ix-dev/community/dashy/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/dashy/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/dashy/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/dashy/templates/library/base_v2_1_8/storage.py b/ix-dev/community/dashy/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/dashy/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/dashy/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/dashy/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/dashy/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/dashy/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/dashy/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/dashy/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/dashy/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/dashy/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/dashy/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/dashy/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/dashy/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/dashy/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/dashy/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/dashy/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/dashy/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/dashy/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/dashy/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/dashy/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/dashy/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/dashy/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/dashy/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/dashy/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/dashy/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/dashy/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/dashy/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/dashy/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/dashy/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/dashy/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/dashy/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/dashy/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/dashy/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/dashy/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/dashy/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/dashy/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/dashy/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/dashy/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/dashy/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/dashy/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/dashy/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/dashy/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/dashy/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/dashy/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/dashy/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/dashy/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/dashy/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/dashy/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/dashy/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/dashy/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/dashy/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/dashy/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/dashy/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/dashy/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/dashy/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/dashy/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/dashy/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/dashy/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/dashy/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/dashy/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/dashy/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/dashy/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/dashy/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/dashy/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/dashy/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/dashy/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/dashy/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/dashy/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/dashy/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/dashy/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/dashy/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/dashy/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/dashy/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/dashy/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/dashy/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/dashy/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/dashy/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/dashy/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/dashy/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/dashy/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/dashy/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/dashy/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/dashy/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/dashy/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/dashy/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/dashy/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/dashy/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/dashy/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/dashy/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/dashy/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/dashy/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/dashy/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/dashy/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/dashy/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/dashy/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/dashy/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/dashy/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/dashy/templates/library/base_v2_1_8/validations.py b/ix-dev/community/dashy/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/dashy/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/dashy/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/dashy/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/dashy/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/dashy/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/dashy/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/dashy/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/dashy/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/dashy/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/dashy/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/dashy/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/dashy/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/dashy/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/dashy/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/dashy/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/dashy/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/dashy/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/dashy/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/dashy/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/dashy/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/dashy/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/dashy/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/ddns-updater/app.yaml b/ix-dev/community/ddns-updater/app.yaml index 73423554e1..71628ae8c3 100644 --- a/ix-dev/community/ddns-updater/app.yaml +++ b/ix-dev/community/ddns-updater/app.yaml @@ -9,8 +9,8 @@ icon: https://media.sys.truenas.net/apps/ddns-updater/icons/icon.svg keywords: - ddns-updater - ddns -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -30,4 +30,4 @@ sources: - https://hub.docker.com/r/qmcgaw/ddns-updater title: DDNS Updater train: community -version: 1.1.8 +version: 1.1.9 diff --git a/ix-dev/community/ddns-updater/templates/library/base_v2_1_8/container.py b/ix-dev/community/ddns-updater/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/ddns-updater/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/ddns-updater/templates/library/base_v2_1_8/ports.py b/ix-dev/community/ddns-updater/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/ddns-updater/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/ddns-updater/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/ddns-updater/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/ddns-updater/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/ddns-updater/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/ddns-updater/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/ddns-updater/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/ddns-updater/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/ddns-updater/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/ddns-updater/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/ddns-updater/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/ddns-updater/templates/library/base_v2_1_8/configs.py b/ix-dev/community/ddns-updater/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/ddns-updater/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/ddns-updater/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/ddns-updater/templates/library/base_v2_1_9/container.py b/ix-dev/community/ddns-updater/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/ddns-updater/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/ddns-updater/templates/library/base_v2_1_8/depends.py b/ix-dev/community/ddns-updater/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/ddns-updater/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/ddns-updater/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/ddns-updater/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/ddns-updater/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/ddns-updater/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/ddns-updater/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/ddns-updater/templates/library/base_v2_1_8/deps.py b/ix-dev/community/ddns-updater/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/ddns-updater/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/ddns-updater/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/ddns-updater/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/ddns-updater/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/ddns-updater/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/ddns-updater/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/ddns-updater/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/ddns-updater/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/ddns-updater/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/ddns-updater/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/ddns-updater/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/ddns-updater/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/ddns-updater/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/ddns-updater/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/ddns-updater/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/ddns-updater/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/ddns-updater/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/ddns-updater/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/ddns-updater/templates/library/base_v2_1_8/device.py b/ix-dev/community/ddns-updater/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/ddns-updater/templates/library/base_v2_1_8/device.py rename to ix-dev/community/ddns-updater/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/ddns-updater/templates/library/base_v2_1_8/devices.py b/ix-dev/community/ddns-updater/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/ddns-updater/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/ddns-updater/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/ddns-updater/templates/library/base_v2_1_8/dns.py b/ix-dev/community/ddns-updater/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/ddns-updater/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/ddns-updater/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/ddns-updater/templates/library/base_v2_1_8/environment.py b/ix-dev/community/ddns-updater/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/ddns-updater/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/ddns-updater/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/ddns-updater/templates/library/base_v2_1_8/error.py b/ix-dev/community/ddns-updater/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/ddns-updater/templates/library/base_v2_1_8/error.py rename to ix-dev/community/ddns-updater/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/ddns-updater/templates/library/base_v2_1_8/expose.py b/ix-dev/community/ddns-updater/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/ddns-updater/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/ddns-updater/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/ddns-updater/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/ddns-updater/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/ddns-updater/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/ddns-updater/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/ddns-updater/templates/library/base_v2_1_8/functions.py b/ix-dev/community/ddns-updater/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/ddns-updater/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/ddns-updater/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/ddns-updater/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/ddns-updater/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/ddns-updater/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/ddns-updater/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/ddns-updater/templates/library/base_v2_1_8/labels.py b/ix-dev/community/ddns-updater/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/ddns-updater/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/ddns-updater/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/ddns-updater/templates/library/base_v2_1_8/notes.py b/ix-dev/community/ddns-updater/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/ddns-updater/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/ddns-updater/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/ddns-updater/templates/library/base_v2_1_8/portal.py b/ix-dev/community/ddns-updater/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/ddns-updater/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/ddns-updater/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/ddns-updater/templates/library/base_v2_1_8/portals.py b/ix-dev/community/ddns-updater/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/ddns-updater/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/ddns-updater/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/ddns-updater/templates/library/base_v2_1_9/ports.py b/ix-dev/community/ddns-updater/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/ddns-updater/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/ddns-updater/templates/library/base_v2_1_8/render.py b/ix-dev/community/ddns-updater/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/ddns-updater/templates/library/base_v2_1_8/render.py rename to ix-dev/community/ddns-updater/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/ddns-updater/templates/library/base_v2_1_8/resources.py b/ix-dev/community/ddns-updater/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/ddns-updater/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/ddns-updater/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/ddns-updater/templates/library/base_v2_1_8/restart.py b/ix-dev/community/ddns-updater/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/ddns-updater/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/ddns-updater/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/ddns-updater/templates/library/base_v2_1_8/storage.py b/ix-dev/community/ddns-updater/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/ddns-updater/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/ddns-updater/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/ddns-updater/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/ddns-updater/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/ddns-updater/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/ddns-updater/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/ddns-updater/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/ddns-updater/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/ddns-updater/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/ddns-updater/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/ddns-updater/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/ddns-updater/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/ddns-updater/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/ddns-updater/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/ddns-updater/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/ddns-updater/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/ddns-updater/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/ddns-updater/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/ddns-updater/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/ddns-updater/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/ddns-updater/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/ddns-updater/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/ddns-updater/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/ddns-updater/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/ddns-updater/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/ddns-updater/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/ddns-updater/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/ddns-updater/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/ddns-updater/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/ddns-updater/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/ddns-updater/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/ddns-updater/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/ddns-updater/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/ddns-updater/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/ddns-updater/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/ddns-updater/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/ddns-updater/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/ddns-updater/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/ddns-updater/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/ddns-updater/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/ddns-updater/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/ddns-updater/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/ddns-updater/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/ddns-updater/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/ddns-updater/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/ddns-updater/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/ddns-updater/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/ddns-updater/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/ddns-updater/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/ddns-updater/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/ddns-updater/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/ddns-updater/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/ddns-updater/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/ddns-updater/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/ddns-updater/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/ddns-updater/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/ddns-updater/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/ddns-updater/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/ddns-updater/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/ddns-updater/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/ddns-updater/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/ddns-updater/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/ddns-updater/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/ddns-updater/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/ddns-updater/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/ddns-updater/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/ddns-updater/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/ddns-updater/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/ddns-updater/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/ddns-updater/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/ddns-updater/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/ddns-updater/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/ddns-updater/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/ddns-updater/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/ddns-updater/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/ddns-updater/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/ddns-updater/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/ddns-updater/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/ddns-updater/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/ddns-updater/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/ddns-updater/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/ddns-updater/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/ddns-updater/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/ddns-updater/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/ddns-updater/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/ddns-updater/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/ddns-updater/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/ddns-updater/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/ddns-updater/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/ddns-updater/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/ddns-updater/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/ddns-updater/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/ddns-updater/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/ddns-updater/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/ddns-updater/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/ddns-updater/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/ddns-updater/templates/library/base_v2_1_8/validations.py b/ix-dev/community/ddns-updater/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/ddns-updater/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/ddns-updater/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/ddns-updater/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/ddns-updater/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/ddns-updater/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/ddns-updater/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/ddns-updater/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/ddns-updater/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/ddns-updater/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/ddns-updater/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/ddns-updater/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/ddns-updater/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/ddns-updater/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/ddns-updater/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/ddns-updater/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/ddns-updater/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/ddns-updater/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/ddns-updater/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/ddns-updater/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/ddns-updater/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/ddns-updater/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/ddns-updater/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/ddns-updater/templates/library/community/ddns-updater/v1_1_8/__init__.py b/ix-dev/community/ddns-updater/templates/library/community/ddns-updater/v1_1_9/__init__.py similarity index 100% rename from ix-dev/community/ddns-updater/templates/library/community/ddns-updater/v1_1_8/__init__.py rename to ix-dev/community/ddns-updater/templates/library/community/ddns-updater/v1_1_9/__init__.py diff --git a/ix-dev/community/ddns-updater/templates/library/community/ddns-updater/v1_1_8/config.py b/ix-dev/community/ddns-updater/templates/library/community/ddns-updater/v1_1_9/config.py similarity index 100% rename from ix-dev/community/ddns-updater/templates/library/community/ddns-updater/v1_1_8/config.py rename to ix-dev/community/ddns-updater/templates/library/community/ddns-updater/v1_1_9/config.py diff --git a/ix-dev/community/deluge/app.yaml b/ix-dev/community/deluge/app.yaml index 5c1a6390d6..f4b713a0c3 100644 --- a/ix-dev/community/deluge/app.yaml +++ b/ix-dev/community/deluge/app.yaml @@ -19,8 +19,8 @@ icon: https://media.sys.truenas.net/apps/deluge/icons/icon.png keywords: - torrent - download -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -38,4 +38,4 @@ sources: - https://deluge-torrent.org/ title: Deluge train: community -version: 1.1.6 +version: 1.1.7 diff --git a/ix-dev/community/deluge/templates/library/base_v2_1_8/container.py b/ix-dev/community/deluge/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/deluge/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/deluge/templates/library/base_v2_1_8/ports.py b/ix-dev/community/deluge/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/deluge/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/deluge/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/deluge/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/deluge/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/deluge/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/deluge/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/deluge/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/deluge/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/deluge/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/deluge/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/deluge/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/deluge/templates/library/base_v2_1_8/configs.py b/ix-dev/community/deluge/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/deluge/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/deluge/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/deluge/templates/library/base_v2_1_9/container.py b/ix-dev/community/deluge/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/deluge/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/deluge/templates/library/base_v2_1_8/depends.py b/ix-dev/community/deluge/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/deluge/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/deluge/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/deluge/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/deluge/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/deluge/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/deluge/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/deluge/templates/library/base_v2_1_8/deps.py b/ix-dev/community/deluge/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/deluge/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/deluge/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/deluge/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/deluge/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/deluge/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/deluge/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/deluge/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/deluge/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/deluge/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/deluge/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/deluge/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/deluge/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/deluge/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/deluge/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/deluge/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/deluge/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/deluge/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/deluge/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/deluge/templates/library/base_v2_1_8/device.py b/ix-dev/community/deluge/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/deluge/templates/library/base_v2_1_8/device.py rename to ix-dev/community/deluge/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/deluge/templates/library/base_v2_1_8/devices.py b/ix-dev/community/deluge/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/deluge/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/deluge/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/deluge/templates/library/base_v2_1_8/dns.py b/ix-dev/community/deluge/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/deluge/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/deluge/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/deluge/templates/library/base_v2_1_8/environment.py b/ix-dev/community/deluge/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/deluge/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/deluge/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/deluge/templates/library/base_v2_1_8/error.py b/ix-dev/community/deluge/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/deluge/templates/library/base_v2_1_8/error.py rename to ix-dev/community/deluge/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/deluge/templates/library/base_v2_1_8/expose.py b/ix-dev/community/deluge/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/deluge/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/deluge/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/deluge/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/deluge/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/deluge/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/deluge/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/deluge/templates/library/base_v2_1_8/functions.py b/ix-dev/community/deluge/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/deluge/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/deluge/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/deluge/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/deluge/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/deluge/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/deluge/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/deluge/templates/library/base_v2_1_8/labels.py b/ix-dev/community/deluge/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/deluge/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/deluge/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/deluge/templates/library/base_v2_1_8/notes.py b/ix-dev/community/deluge/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/deluge/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/deluge/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/deluge/templates/library/base_v2_1_8/portal.py b/ix-dev/community/deluge/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/deluge/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/deluge/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/deluge/templates/library/base_v2_1_8/portals.py b/ix-dev/community/deluge/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/deluge/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/deluge/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/deluge/templates/library/base_v2_1_9/ports.py b/ix-dev/community/deluge/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/deluge/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/deluge/templates/library/base_v2_1_8/render.py b/ix-dev/community/deluge/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/deluge/templates/library/base_v2_1_8/render.py rename to ix-dev/community/deluge/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/deluge/templates/library/base_v2_1_8/resources.py b/ix-dev/community/deluge/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/deluge/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/deluge/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/deluge/templates/library/base_v2_1_8/restart.py b/ix-dev/community/deluge/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/deluge/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/deluge/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/deluge/templates/library/base_v2_1_8/storage.py b/ix-dev/community/deluge/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/deluge/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/deluge/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/deluge/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/deluge/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/deluge/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/deluge/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/deluge/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/deluge/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/deluge/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/deluge/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/deluge/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/deluge/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/deluge/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/deluge/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/deluge/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/deluge/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/deluge/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/deluge/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/deluge/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/deluge/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/deluge/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/deluge/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/deluge/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/deluge/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/deluge/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/deluge/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/deluge/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/deluge/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/deluge/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/deluge/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/deluge/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/deluge/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/deluge/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/deluge/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/deluge/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/deluge/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/deluge/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/deluge/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/deluge/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/deluge/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/deluge/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/deluge/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/deluge/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/deluge/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/deluge/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/deluge/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/deluge/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/deluge/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/deluge/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/deluge/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/deluge/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/deluge/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/deluge/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/deluge/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/deluge/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/deluge/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/deluge/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/deluge/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/deluge/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/deluge/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/deluge/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/deluge/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/deluge/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/deluge/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/deluge/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/deluge/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/deluge/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/deluge/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/deluge/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/deluge/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/deluge/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/deluge/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/deluge/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/deluge/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/deluge/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/deluge/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/deluge/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/deluge/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/deluge/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/deluge/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/deluge/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/deluge/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/deluge/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/deluge/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/deluge/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/deluge/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/deluge/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/deluge/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/deluge/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/deluge/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/deluge/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/deluge/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/deluge/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/deluge/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/deluge/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/deluge/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/deluge/templates/library/base_v2_1_8/validations.py b/ix-dev/community/deluge/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/deluge/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/deluge/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/deluge/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/deluge/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/deluge/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/deluge/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/deluge/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/deluge/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/deluge/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/deluge/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/deluge/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/deluge/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/deluge/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/deluge/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/deluge/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/deluge/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/deluge/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/deluge/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/deluge/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/deluge/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/deluge/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/deluge/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/distribution/app.yaml b/ix-dev/community/distribution/app.yaml index 00e3e76dc9..c35b9b90e0 100644 --- a/ix-dev/community/distribution/app.yaml +++ b/ix-dev/community/distribution/app.yaml @@ -10,8 +10,8 @@ icon: https://media.sys.truenas.net/apps/distribution/icons/icon.svg keywords: - registry - container -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -30,4 +30,4 @@ sources: - https://github.com/distribution/distribution title: Distribution train: community -version: 1.1.6 +version: 1.1.7 diff --git a/ix-dev/community/distribution/templates/library/base_v2_1_8/container.py b/ix-dev/community/distribution/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/distribution/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/distribution/templates/library/base_v2_1_8/ports.py b/ix-dev/community/distribution/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/distribution/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/distribution/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/distribution/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/distribution/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/distribution/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/distribution/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/distribution/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/distribution/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/distribution/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/distribution/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/distribution/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/distribution/templates/library/base_v2_1_8/configs.py b/ix-dev/community/distribution/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/distribution/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/distribution/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/distribution/templates/library/base_v2_1_9/container.py b/ix-dev/community/distribution/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/distribution/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/distribution/templates/library/base_v2_1_8/depends.py b/ix-dev/community/distribution/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/distribution/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/distribution/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/distribution/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/distribution/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/distribution/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/distribution/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/distribution/templates/library/base_v2_1_8/deps.py b/ix-dev/community/distribution/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/distribution/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/distribution/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/distribution/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/distribution/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/distribution/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/distribution/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/distribution/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/distribution/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/distribution/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/distribution/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/distribution/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/distribution/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/distribution/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/distribution/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/distribution/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/distribution/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/distribution/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/distribution/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/distribution/templates/library/base_v2_1_8/device.py b/ix-dev/community/distribution/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/distribution/templates/library/base_v2_1_8/device.py rename to ix-dev/community/distribution/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/distribution/templates/library/base_v2_1_8/devices.py b/ix-dev/community/distribution/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/distribution/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/distribution/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/distribution/templates/library/base_v2_1_8/dns.py b/ix-dev/community/distribution/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/distribution/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/distribution/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/distribution/templates/library/base_v2_1_8/environment.py b/ix-dev/community/distribution/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/distribution/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/distribution/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/distribution/templates/library/base_v2_1_8/error.py b/ix-dev/community/distribution/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/distribution/templates/library/base_v2_1_8/error.py rename to ix-dev/community/distribution/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/distribution/templates/library/base_v2_1_8/expose.py b/ix-dev/community/distribution/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/distribution/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/distribution/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/distribution/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/distribution/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/distribution/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/distribution/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/distribution/templates/library/base_v2_1_8/functions.py b/ix-dev/community/distribution/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/distribution/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/distribution/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/distribution/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/distribution/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/distribution/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/distribution/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/distribution/templates/library/base_v2_1_8/labels.py b/ix-dev/community/distribution/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/distribution/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/distribution/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/distribution/templates/library/base_v2_1_8/notes.py b/ix-dev/community/distribution/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/distribution/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/distribution/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/distribution/templates/library/base_v2_1_8/portal.py b/ix-dev/community/distribution/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/distribution/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/distribution/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/distribution/templates/library/base_v2_1_8/portals.py b/ix-dev/community/distribution/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/distribution/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/distribution/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/distribution/templates/library/base_v2_1_9/ports.py b/ix-dev/community/distribution/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/distribution/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/distribution/templates/library/base_v2_1_8/render.py b/ix-dev/community/distribution/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/distribution/templates/library/base_v2_1_8/render.py rename to ix-dev/community/distribution/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/distribution/templates/library/base_v2_1_8/resources.py b/ix-dev/community/distribution/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/distribution/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/distribution/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/distribution/templates/library/base_v2_1_8/restart.py b/ix-dev/community/distribution/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/distribution/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/distribution/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/distribution/templates/library/base_v2_1_8/storage.py b/ix-dev/community/distribution/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/distribution/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/distribution/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/distribution/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/distribution/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/distribution/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/distribution/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/distribution/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/distribution/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/distribution/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/distribution/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/distribution/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/distribution/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/distribution/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/distribution/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/distribution/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/distribution/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/distribution/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/distribution/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/distribution/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/distribution/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/distribution/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/distribution/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/distribution/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/distribution/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/distribution/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/distribution/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/distribution/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/distribution/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/distribution/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/distribution/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/distribution/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/distribution/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/distribution/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/distribution/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/distribution/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/distribution/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/distribution/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/distribution/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/distribution/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/distribution/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/distribution/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/distribution/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/distribution/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/distribution/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/distribution/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/distribution/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/distribution/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/distribution/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/distribution/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/distribution/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/distribution/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/distribution/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/distribution/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/distribution/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/distribution/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/distribution/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/distribution/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/distribution/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/distribution/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/distribution/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/distribution/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/distribution/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/distribution/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/distribution/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/distribution/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/distribution/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/distribution/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/distribution/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/distribution/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/distribution/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/distribution/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/distribution/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/distribution/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/distribution/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/distribution/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/distribution/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/distribution/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/distribution/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/distribution/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/distribution/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/distribution/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/distribution/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/distribution/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/distribution/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/distribution/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/distribution/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/distribution/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/distribution/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/distribution/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/distribution/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/distribution/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/distribution/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/distribution/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/distribution/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/distribution/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/distribution/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/distribution/templates/library/base_v2_1_8/validations.py b/ix-dev/community/distribution/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/distribution/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/distribution/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/distribution/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/distribution/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/distribution/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/distribution/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/distribution/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/distribution/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/distribution/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/distribution/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/distribution/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/distribution/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/distribution/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/distribution/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/distribution/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/distribution/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/distribution/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/distribution/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/distribution/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/distribution/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/distribution/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/distribution/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/dockge/app.yaml b/ix-dev/community/dockge/app.yaml index e118eb07ca..8765f9ff71 100644 --- a/ix-dev/community/dockge/app.yaml +++ b/ix-dev/community/dockge/app.yaml @@ -28,8 +28,8 @@ icon: https://media.sys.truenas.net/apps/dockge/icons/icon.svg keywords: - docker - compose -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -48,4 +48,4 @@ sources: - https://github.com/louislam/dockge title: Dockge train: community -version: 1.1.7 +version: 1.1.8 diff --git a/ix-dev/community/dockge/templates/library/base_v2_1_8/container.py b/ix-dev/community/dockge/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/dockge/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/dockge/templates/library/base_v2_1_8/ports.py b/ix-dev/community/dockge/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/dockge/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/dockge/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/dockge/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/dockge/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/dockge/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/dockge/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/dockge/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/dockge/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/dockge/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/dockge/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/dockge/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/dockge/templates/library/base_v2_1_8/configs.py b/ix-dev/community/dockge/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/dockge/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/dockge/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/dockge/templates/library/base_v2_1_9/container.py b/ix-dev/community/dockge/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/dockge/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/dockge/templates/library/base_v2_1_8/depends.py b/ix-dev/community/dockge/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/dockge/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/dockge/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/dockge/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/dockge/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/dockge/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/dockge/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/dockge/templates/library/base_v2_1_8/deps.py b/ix-dev/community/dockge/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/dockge/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/dockge/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/dockge/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/dockge/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/dockge/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/dockge/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/dockge/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/dockge/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/dockge/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/dockge/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/dockge/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/dockge/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/dockge/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/dockge/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/dockge/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/dockge/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/dockge/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/dockge/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/dockge/templates/library/base_v2_1_8/device.py b/ix-dev/community/dockge/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/dockge/templates/library/base_v2_1_8/device.py rename to ix-dev/community/dockge/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/dockge/templates/library/base_v2_1_8/devices.py b/ix-dev/community/dockge/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/dockge/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/dockge/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/dockge/templates/library/base_v2_1_8/dns.py b/ix-dev/community/dockge/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/dockge/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/dockge/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/dockge/templates/library/base_v2_1_8/environment.py b/ix-dev/community/dockge/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/dockge/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/dockge/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/dockge/templates/library/base_v2_1_8/error.py b/ix-dev/community/dockge/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/dockge/templates/library/base_v2_1_8/error.py rename to ix-dev/community/dockge/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/dockge/templates/library/base_v2_1_8/expose.py b/ix-dev/community/dockge/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/dockge/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/dockge/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/dockge/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/dockge/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/dockge/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/dockge/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/dockge/templates/library/base_v2_1_8/functions.py b/ix-dev/community/dockge/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/dockge/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/dockge/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/dockge/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/dockge/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/dockge/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/dockge/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/dockge/templates/library/base_v2_1_8/labels.py b/ix-dev/community/dockge/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/dockge/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/dockge/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/dockge/templates/library/base_v2_1_8/notes.py b/ix-dev/community/dockge/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/dockge/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/dockge/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/dockge/templates/library/base_v2_1_8/portal.py b/ix-dev/community/dockge/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/dockge/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/dockge/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/dockge/templates/library/base_v2_1_8/portals.py b/ix-dev/community/dockge/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/dockge/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/dockge/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/dockge/templates/library/base_v2_1_9/ports.py b/ix-dev/community/dockge/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/dockge/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/dockge/templates/library/base_v2_1_8/render.py b/ix-dev/community/dockge/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/dockge/templates/library/base_v2_1_8/render.py rename to ix-dev/community/dockge/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/dockge/templates/library/base_v2_1_8/resources.py b/ix-dev/community/dockge/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/dockge/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/dockge/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/dockge/templates/library/base_v2_1_8/restart.py b/ix-dev/community/dockge/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/dockge/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/dockge/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/dockge/templates/library/base_v2_1_8/storage.py b/ix-dev/community/dockge/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/dockge/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/dockge/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/dockge/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/dockge/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/dockge/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/dockge/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/dockge/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/dockge/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/dockge/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/dockge/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/dockge/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/dockge/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/dockge/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/dockge/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/dockge/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/dockge/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/dockge/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/dockge/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/dockge/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/dockge/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/dockge/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/dockge/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/dockge/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/dockge/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/dockge/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/dockge/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/dockge/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/dockge/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/dockge/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/dockge/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/dockge/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/dockge/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/dockge/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/dockge/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/dockge/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/dockge/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/dockge/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/dockge/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/dockge/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/dockge/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/dockge/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/dockge/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/dockge/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/dockge/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/dockge/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/dockge/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/dockge/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/dockge/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/dockge/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/dockge/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/dockge/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/dockge/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/dockge/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/dockge/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/dockge/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/dockge/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/dockge/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/dockge/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/dockge/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/dockge/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/dockge/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/dockge/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/dockge/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/dockge/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/dockge/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/dockge/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/dockge/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/dockge/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/dockge/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/dockge/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/dockge/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/dockge/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/dockge/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/dockge/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/dockge/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/dockge/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/dockge/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/dockge/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/dockge/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/dockge/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/dockge/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/dockge/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/dockge/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/dockge/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/dockge/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/dockge/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/dockge/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/dockge/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/dockge/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/dockge/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/dockge/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/dockge/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/dockge/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/dockge/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/dockge/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/dockge/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/dockge/templates/library/base_v2_1_8/validations.py b/ix-dev/community/dockge/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/dockge/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/dockge/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/dockge/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/dockge/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/dockge/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/dockge/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/dockge/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/dockge/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/dockge/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/dockge/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/dockge/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/dockge/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/dockge/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/dockge/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/dockge/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/dockge/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/dockge/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/dockge/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/dockge/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/dockge/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/dockge/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/dockge/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/drawio/app.yaml b/ix-dev/community/drawio/app.yaml index b866175d71..b7b6949af5 100644 --- a/ix-dev/community/drawio/app.yaml +++ b/ix-dev/community/drawio/app.yaml @@ -9,8 +9,8 @@ icon: https://media.sys.truenas.net/apps/drawio/icons/icon.png keywords: - diagram - whiteboard -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -32,4 +32,4 @@ sources: - https://github.com/jgraph/drawio title: Draw.io train: community -version: 1.2.9 +version: 1.2.10 diff --git a/ix-dev/community/drawio/templates/library/base_v2_1_8/container.py b/ix-dev/community/drawio/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/drawio/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/drawio/templates/library/base_v2_1_8/ports.py b/ix-dev/community/drawio/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/drawio/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/drawio/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/drawio/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/drawio/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/drawio/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/drawio/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/drawio/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/drawio/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/drawio/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/drawio/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/drawio/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/drawio/templates/library/base_v2_1_8/configs.py b/ix-dev/community/drawio/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/drawio/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/drawio/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/drawio/templates/library/base_v2_1_9/container.py b/ix-dev/community/drawio/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/drawio/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/drawio/templates/library/base_v2_1_8/depends.py b/ix-dev/community/drawio/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/drawio/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/drawio/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/drawio/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/drawio/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/drawio/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/drawio/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/drawio/templates/library/base_v2_1_8/deps.py b/ix-dev/community/drawio/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/drawio/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/drawio/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/drawio/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/drawio/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/drawio/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/drawio/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/drawio/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/drawio/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/drawio/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/drawio/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/drawio/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/drawio/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/drawio/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/drawio/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/drawio/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/drawio/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/drawio/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/drawio/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/drawio/templates/library/base_v2_1_8/device.py b/ix-dev/community/drawio/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/drawio/templates/library/base_v2_1_8/device.py rename to ix-dev/community/drawio/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/drawio/templates/library/base_v2_1_8/devices.py b/ix-dev/community/drawio/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/drawio/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/drawio/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/drawio/templates/library/base_v2_1_8/dns.py b/ix-dev/community/drawio/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/drawio/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/drawio/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/drawio/templates/library/base_v2_1_8/environment.py b/ix-dev/community/drawio/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/drawio/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/drawio/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/drawio/templates/library/base_v2_1_8/error.py b/ix-dev/community/drawio/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/drawio/templates/library/base_v2_1_8/error.py rename to ix-dev/community/drawio/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/drawio/templates/library/base_v2_1_8/expose.py b/ix-dev/community/drawio/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/drawio/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/drawio/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/drawio/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/drawio/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/drawio/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/drawio/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/drawio/templates/library/base_v2_1_8/functions.py b/ix-dev/community/drawio/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/drawio/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/drawio/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/drawio/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/drawio/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/drawio/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/drawio/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/drawio/templates/library/base_v2_1_8/labels.py b/ix-dev/community/drawio/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/drawio/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/drawio/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/drawio/templates/library/base_v2_1_8/notes.py b/ix-dev/community/drawio/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/drawio/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/drawio/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/drawio/templates/library/base_v2_1_8/portal.py b/ix-dev/community/drawio/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/drawio/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/drawio/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/drawio/templates/library/base_v2_1_8/portals.py b/ix-dev/community/drawio/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/drawio/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/drawio/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/drawio/templates/library/base_v2_1_9/ports.py b/ix-dev/community/drawio/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/drawio/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/drawio/templates/library/base_v2_1_8/render.py b/ix-dev/community/drawio/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/drawio/templates/library/base_v2_1_8/render.py rename to ix-dev/community/drawio/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/drawio/templates/library/base_v2_1_8/resources.py b/ix-dev/community/drawio/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/drawio/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/drawio/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/drawio/templates/library/base_v2_1_8/restart.py b/ix-dev/community/drawio/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/drawio/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/drawio/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/drawio/templates/library/base_v2_1_8/storage.py b/ix-dev/community/drawio/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/drawio/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/drawio/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/drawio/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/drawio/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/drawio/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/drawio/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/drawio/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/drawio/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/drawio/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/drawio/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/drawio/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/drawio/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/drawio/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/drawio/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/drawio/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/drawio/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/drawio/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/drawio/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/drawio/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/drawio/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/drawio/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/drawio/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/drawio/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/drawio/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/drawio/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/drawio/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/drawio/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/drawio/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/drawio/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/drawio/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/drawio/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/drawio/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/drawio/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/drawio/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/drawio/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/drawio/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/drawio/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/drawio/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/drawio/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/drawio/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/drawio/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/drawio/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/drawio/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/drawio/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/drawio/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/drawio/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/drawio/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/drawio/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/drawio/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/drawio/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/drawio/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/drawio/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/drawio/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/drawio/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/drawio/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/drawio/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/drawio/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/drawio/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/drawio/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/drawio/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/drawio/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/drawio/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/drawio/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/drawio/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/drawio/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/drawio/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/drawio/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/drawio/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/drawio/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/drawio/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/drawio/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/drawio/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/drawio/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/drawio/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/drawio/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/drawio/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/drawio/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/drawio/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/drawio/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/drawio/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/drawio/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/drawio/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/drawio/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/drawio/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/drawio/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/drawio/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/drawio/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/drawio/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/drawio/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/drawio/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/drawio/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/drawio/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/drawio/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/drawio/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/drawio/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/drawio/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/drawio/templates/library/base_v2_1_8/validations.py b/ix-dev/community/drawio/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/drawio/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/drawio/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/drawio/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/drawio/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/drawio/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/drawio/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/drawio/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/drawio/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/drawio/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/drawio/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/drawio/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/drawio/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/drawio/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/drawio/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/drawio/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/drawio/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/drawio/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/drawio/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/drawio/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/drawio/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/drawio/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/drawio/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/eclipse-mosquitto/app.yaml b/ix-dev/community/eclipse-mosquitto/app.yaml index c42e1ef238..b7425a7455 100644 --- a/ix-dev/community/eclipse-mosquitto/app.yaml +++ b/ix-dev/community/eclipse-mosquitto/app.yaml @@ -9,8 +9,8 @@ icon: https://media.sys.truenas.net/apps/eclipse-mosquitto/icons/icon.svg keywords: - networking - mqtt -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -28,4 +28,4 @@ sources: - https://hub.docker.com/_/eclipse-mosquitto title: Eclipse Mosquitto train: community -version: 1.0.7 +version: 1.0.8 diff --git a/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/container.py b/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/ports.py b/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/configs.py b/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/container.py b/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/depends.py b/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/deps.py b/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/device.py b/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/device.py rename to ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/devices.py b/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/dns.py b/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/environment.py b/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/error.py b/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/error.py rename to ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/expose.py b/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/functions.py b/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/labels.py b/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/notes.py b/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/portal.py b/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/portals.py b/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/ports.py b/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/render.py b/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/render.py rename to ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/resources.py b/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/restart.py b/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/storage.py b/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/validations.py b/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/eclipse-mosquitto/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/filebrowser/app.yaml b/ix-dev/community/filebrowser/app.yaml index 392fda4533..a8105c88b3 100644 --- a/ix-dev/community/filebrowser/app.yaml +++ b/ix-dev/community/filebrowser/app.yaml @@ -10,8 +10,8 @@ icon: https://media.sys.truenas.net/apps/filebrowser/icons/icon.png keywords: - files - browser -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -33,4 +33,4 @@ sources: - https://hub.docker.com/r/filebrowser/filebrowser title: File Browser train: community -version: 1.2.5 +version: 1.2.6 diff --git a/ix-dev/community/filebrowser/templates/library/base_v2_1_8/container.py b/ix-dev/community/filebrowser/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/filebrowser/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/filebrowser/templates/library/base_v2_1_8/ports.py b/ix-dev/community/filebrowser/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/filebrowser/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/filebrowser/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/filebrowser/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/filebrowser/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/filebrowser/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/filebrowser/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/filebrowser/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/filebrowser/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/filebrowser/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/filebrowser/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/filebrowser/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/filebrowser/templates/library/base_v2_1_8/configs.py b/ix-dev/community/filebrowser/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/filebrowser/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/filebrowser/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/filebrowser/templates/library/base_v2_1_9/container.py b/ix-dev/community/filebrowser/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/filebrowser/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/filebrowser/templates/library/base_v2_1_8/depends.py b/ix-dev/community/filebrowser/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/filebrowser/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/filebrowser/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/filebrowser/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/filebrowser/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/filebrowser/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/filebrowser/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/filebrowser/templates/library/base_v2_1_8/deps.py b/ix-dev/community/filebrowser/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/filebrowser/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/filebrowser/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/filebrowser/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/filebrowser/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/filebrowser/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/filebrowser/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/filebrowser/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/filebrowser/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/filebrowser/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/filebrowser/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/filebrowser/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/filebrowser/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/filebrowser/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/filebrowser/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/filebrowser/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/filebrowser/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/filebrowser/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/filebrowser/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/filebrowser/templates/library/base_v2_1_8/device.py b/ix-dev/community/filebrowser/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/filebrowser/templates/library/base_v2_1_8/device.py rename to ix-dev/community/filebrowser/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/filebrowser/templates/library/base_v2_1_8/devices.py b/ix-dev/community/filebrowser/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/filebrowser/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/filebrowser/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/filebrowser/templates/library/base_v2_1_8/dns.py b/ix-dev/community/filebrowser/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/filebrowser/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/filebrowser/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/filebrowser/templates/library/base_v2_1_8/environment.py b/ix-dev/community/filebrowser/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/filebrowser/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/filebrowser/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/filebrowser/templates/library/base_v2_1_8/error.py b/ix-dev/community/filebrowser/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/filebrowser/templates/library/base_v2_1_8/error.py rename to ix-dev/community/filebrowser/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/filebrowser/templates/library/base_v2_1_8/expose.py b/ix-dev/community/filebrowser/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/filebrowser/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/filebrowser/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/filebrowser/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/filebrowser/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/filebrowser/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/filebrowser/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/filebrowser/templates/library/base_v2_1_8/functions.py b/ix-dev/community/filebrowser/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/filebrowser/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/filebrowser/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/filebrowser/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/filebrowser/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/filebrowser/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/filebrowser/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/filebrowser/templates/library/base_v2_1_8/labels.py b/ix-dev/community/filebrowser/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/filebrowser/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/filebrowser/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/filebrowser/templates/library/base_v2_1_8/notes.py b/ix-dev/community/filebrowser/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/filebrowser/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/filebrowser/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/filebrowser/templates/library/base_v2_1_8/portal.py b/ix-dev/community/filebrowser/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/filebrowser/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/filebrowser/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/filebrowser/templates/library/base_v2_1_8/portals.py b/ix-dev/community/filebrowser/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/filebrowser/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/filebrowser/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/filebrowser/templates/library/base_v2_1_9/ports.py b/ix-dev/community/filebrowser/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/filebrowser/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/filebrowser/templates/library/base_v2_1_8/render.py b/ix-dev/community/filebrowser/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/filebrowser/templates/library/base_v2_1_8/render.py rename to ix-dev/community/filebrowser/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/filebrowser/templates/library/base_v2_1_8/resources.py b/ix-dev/community/filebrowser/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/filebrowser/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/filebrowser/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/filebrowser/templates/library/base_v2_1_8/restart.py b/ix-dev/community/filebrowser/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/filebrowser/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/filebrowser/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/filebrowser/templates/library/base_v2_1_8/storage.py b/ix-dev/community/filebrowser/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/filebrowser/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/filebrowser/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/filebrowser/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/filebrowser/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/filebrowser/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/filebrowser/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/filebrowser/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/filebrowser/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/filebrowser/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/filebrowser/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/filebrowser/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/filebrowser/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/filebrowser/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/filebrowser/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/filebrowser/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/filebrowser/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/filebrowser/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/filebrowser/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/filebrowser/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/filebrowser/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/filebrowser/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/filebrowser/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/filebrowser/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/filebrowser/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/filebrowser/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/filebrowser/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/filebrowser/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/filebrowser/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/filebrowser/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/filebrowser/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/filebrowser/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/filebrowser/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/filebrowser/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/filebrowser/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/filebrowser/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/filebrowser/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/filebrowser/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/filebrowser/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/filebrowser/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/filebrowser/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/filebrowser/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/filebrowser/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/filebrowser/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/filebrowser/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/filebrowser/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/filebrowser/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/filebrowser/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/filebrowser/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/filebrowser/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/filebrowser/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/filebrowser/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/filebrowser/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/filebrowser/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/filebrowser/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/filebrowser/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/filebrowser/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/filebrowser/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/filebrowser/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/filebrowser/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/filebrowser/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/filebrowser/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/filebrowser/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/filebrowser/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/filebrowser/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/filebrowser/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/filebrowser/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/filebrowser/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/filebrowser/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/filebrowser/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/filebrowser/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/filebrowser/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/filebrowser/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/filebrowser/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/filebrowser/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/filebrowser/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/filebrowser/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/filebrowser/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/filebrowser/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/filebrowser/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/filebrowser/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/filebrowser/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/filebrowser/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/filebrowser/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/filebrowser/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/filebrowser/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/filebrowser/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/filebrowser/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/filebrowser/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/filebrowser/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/filebrowser/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/filebrowser/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/filebrowser/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/filebrowser/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/filebrowser/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/filebrowser/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/filebrowser/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/filebrowser/templates/library/base_v2_1_8/validations.py b/ix-dev/community/filebrowser/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/filebrowser/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/filebrowser/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/filebrowser/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/filebrowser/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/filebrowser/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/filebrowser/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/filebrowser/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/filebrowser/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/filebrowser/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/filebrowser/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/filebrowser/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/filebrowser/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/filebrowser/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/filebrowser/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/filebrowser/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/filebrowser/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/filebrowser/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/filebrowser/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/filebrowser/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/filebrowser/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/filebrowser/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/filebrowser/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/filestash/app.yaml b/ix-dev/community/filestash/app.yaml index a32ef287b7..34f024b5b0 100644 --- a/ix-dev/community/filestash/app.yaml +++ b/ix-dev/community/filestash/app.yaml @@ -10,8 +10,8 @@ icon: https://media.sys.truenas.net/apps/filestash/icons/icon.svg keywords: - storage - file manager -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -32,4 +32,4 @@ sources: - https://github.com/mickael-kerjean/filestash title: Filestash train: community -version: 1.0.9 +version: 1.0.10 diff --git a/ix-dev/community/filestash/templates/library/base_v2_1_8/container.py b/ix-dev/community/filestash/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/filestash/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/filestash/templates/library/base_v2_1_8/ports.py b/ix-dev/community/filestash/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/filestash/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/filestash/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/filestash/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/filestash/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/filestash/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/filestash/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/filestash/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/filestash/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/filestash/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/filestash/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/filestash/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/filestash/templates/library/base_v2_1_8/configs.py b/ix-dev/community/filestash/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/filestash/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/filestash/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/filestash/templates/library/base_v2_1_9/container.py b/ix-dev/community/filestash/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/filestash/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/filestash/templates/library/base_v2_1_8/depends.py b/ix-dev/community/filestash/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/filestash/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/filestash/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/filestash/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/filestash/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/filestash/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/filestash/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/filestash/templates/library/base_v2_1_8/deps.py b/ix-dev/community/filestash/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/filestash/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/filestash/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/filestash/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/filestash/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/filestash/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/filestash/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/filestash/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/filestash/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/filestash/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/filestash/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/filestash/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/filestash/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/filestash/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/filestash/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/filestash/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/filestash/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/filestash/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/filestash/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/filestash/templates/library/base_v2_1_8/device.py b/ix-dev/community/filestash/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/filestash/templates/library/base_v2_1_8/device.py rename to ix-dev/community/filestash/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/filestash/templates/library/base_v2_1_8/devices.py b/ix-dev/community/filestash/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/filestash/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/filestash/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/filestash/templates/library/base_v2_1_8/dns.py b/ix-dev/community/filestash/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/filestash/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/filestash/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/filestash/templates/library/base_v2_1_8/environment.py b/ix-dev/community/filestash/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/filestash/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/filestash/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/filestash/templates/library/base_v2_1_8/error.py b/ix-dev/community/filestash/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/filestash/templates/library/base_v2_1_8/error.py rename to ix-dev/community/filestash/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/filestash/templates/library/base_v2_1_8/expose.py b/ix-dev/community/filestash/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/filestash/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/filestash/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/filestash/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/filestash/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/filestash/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/filestash/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/filestash/templates/library/base_v2_1_8/functions.py b/ix-dev/community/filestash/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/filestash/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/filestash/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/filestash/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/filestash/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/filestash/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/filestash/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/filestash/templates/library/base_v2_1_8/labels.py b/ix-dev/community/filestash/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/filestash/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/filestash/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/filestash/templates/library/base_v2_1_8/notes.py b/ix-dev/community/filestash/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/filestash/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/filestash/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/filestash/templates/library/base_v2_1_8/portal.py b/ix-dev/community/filestash/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/filestash/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/filestash/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/filestash/templates/library/base_v2_1_8/portals.py b/ix-dev/community/filestash/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/filestash/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/filestash/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/filestash/templates/library/base_v2_1_9/ports.py b/ix-dev/community/filestash/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/filestash/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/filestash/templates/library/base_v2_1_8/render.py b/ix-dev/community/filestash/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/filestash/templates/library/base_v2_1_8/render.py rename to ix-dev/community/filestash/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/filestash/templates/library/base_v2_1_8/resources.py b/ix-dev/community/filestash/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/filestash/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/filestash/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/filestash/templates/library/base_v2_1_8/restart.py b/ix-dev/community/filestash/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/filestash/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/filestash/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/filestash/templates/library/base_v2_1_8/storage.py b/ix-dev/community/filestash/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/filestash/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/filestash/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/filestash/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/filestash/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/filestash/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/filestash/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/filestash/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/filestash/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/filestash/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/filestash/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/filestash/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/filestash/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/filestash/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/filestash/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/filestash/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/filestash/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/filestash/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/filestash/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/filestash/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/filestash/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/filestash/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/filestash/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/filestash/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/filestash/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/filestash/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/filestash/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/filestash/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/filestash/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/filestash/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/filestash/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/filestash/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/filestash/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/filestash/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/filestash/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/filestash/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/filestash/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/filestash/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/filestash/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/filestash/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/filestash/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/filestash/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/filestash/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/filestash/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/filestash/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/filestash/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/filestash/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/filestash/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/filestash/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/filestash/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/filestash/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/filestash/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/filestash/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/filestash/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/filestash/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/filestash/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/filestash/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/filestash/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/filestash/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/filestash/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/filestash/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/filestash/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/filestash/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/filestash/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/filestash/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/filestash/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/filestash/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/filestash/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/filestash/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/filestash/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/filestash/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/filestash/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/filestash/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/filestash/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/filestash/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/filestash/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/filestash/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/filestash/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/filestash/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/filestash/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/filestash/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/filestash/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/filestash/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/filestash/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/filestash/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/filestash/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/filestash/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/filestash/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/filestash/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/filestash/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/filestash/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/filestash/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/filestash/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/filestash/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/filestash/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/filestash/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/filestash/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/filestash/templates/library/base_v2_1_8/validations.py b/ix-dev/community/filestash/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/filestash/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/filestash/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/filestash/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/filestash/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/filestash/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/filestash/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/filestash/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/filestash/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/filestash/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/filestash/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/filestash/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/filestash/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/filestash/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/filestash/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/filestash/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/filestash/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/filestash/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/filestash/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/filestash/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/filestash/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/filestash/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/filestash/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/firefly-iii/app.yaml b/ix-dev/community/firefly-iii/app.yaml index 28dc26febb..1eb1e34699 100644 --- a/ix-dev/community/firefly-iii/app.yaml +++ b/ix-dev/community/firefly-iii/app.yaml @@ -19,8 +19,8 @@ host_mounts: [] icon: https://media.sys.truenas.net/apps/firefly-iii/icons/icon.png keywords: - finance -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -57,4 +57,4 @@ sources: - https://github.com/firefly-iii/firefly-iii title: Firefly III train: community -version: 1.4.9 +version: 1.4.10 diff --git a/ix-dev/community/firefly-iii/templates/library/base_v2_1_8/container.py b/ix-dev/community/firefly-iii/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/firefly-iii/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/firefly-iii/templates/library/base_v2_1_8/ports.py b/ix-dev/community/firefly-iii/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/firefly-iii/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/firefly-iii/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/firefly-iii/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/firefly-iii/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/firefly-iii/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/firefly-iii/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/firefly-iii/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/firefly-iii/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/firefly-iii/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/firefly-iii/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/firefly-iii/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/firefly-iii/templates/library/base_v2_1_8/configs.py b/ix-dev/community/firefly-iii/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/firefly-iii/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/firefly-iii/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/firefly-iii/templates/library/base_v2_1_9/container.py b/ix-dev/community/firefly-iii/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/firefly-iii/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/firefly-iii/templates/library/base_v2_1_8/depends.py b/ix-dev/community/firefly-iii/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/firefly-iii/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/firefly-iii/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/firefly-iii/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/firefly-iii/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/firefly-iii/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/firefly-iii/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/firefly-iii/templates/library/base_v2_1_8/deps.py b/ix-dev/community/firefly-iii/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/firefly-iii/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/firefly-iii/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/firefly-iii/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/firefly-iii/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/firefly-iii/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/firefly-iii/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/firefly-iii/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/firefly-iii/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/firefly-iii/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/firefly-iii/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/firefly-iii/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/firefly-iii/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/firefly-iii/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/firefly-iii/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/firefly-iii/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/firefly-iii/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/firefly-iii/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/firefly-iii/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/firefly-iii/templates/library/base_v2_1_8/device.py b/ix-dev/community/firefly-iii/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/firefly-iii/templates/library/base_v2_1_8/device.py rename to ix-dev/community/firefly-iii/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/firefly-iii/templates/library/base_v2_1_8/devices.py b/ix-dev/community/firefly-iii/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/firefly-iii/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/firefly-iii/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/firefly-iii/templates/library/base_v2_1_8/dns.py b/ix-dev/community/firefly-iii/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/firefly-iii/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/firefly-iii/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/firefly-iii/templates/library/base_v2_1_8/environment.py b/ix-dev/community/firefly-iii/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/firefly-iii/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/firefly-iii/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/firefly-iii/templates/library/base_v2_1_8/error.py b/ix-dev/community/firefly-iii/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/firefly-iii/templates/library/base_v2_1_8/error.py rename to ix-dev/community/firefly-iii/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/firefly-iii/templates/library/base_v2_1_8/expose.py b/ix-dev/community/firefly-iii/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/firefly-iii/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/firefly-iii/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/firefly-iii/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/firefly-iii/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/firefly-iii/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/firefly-iii/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/firefly-iii/templates/library/base_v2_1_8/functions.py b/ix-dev/community/firefly-iii/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/firefly-iii/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/firefly-iii/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/firefly-iii/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/firefly-iii/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/firefly-iii/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/firefly-iii/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/firefly-iii/templates/library/base_v2_1_8/labels.py b/ix-dev/community/firefly-iii/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/firefly-iii/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/firefly-iii/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/firefly-iii/templates/library/base_v2_1_8/notes.py b/ix-dev/community/firefly-iii/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/firefly-iii/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/firefly-iii/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/firefly-iii/templates/library/base_v2_1_8/portal.py b/ix-dev/community/firefly-iii/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/firefly-iii/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/firefly-iii/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/firefly-iii/templates/library/base_v2_1_8/portals.py b/ix-dev/community/firefly-iii/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/firefly-iii/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/firefly-iii/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/firefly-iii/templates/library/base_v2_1_9/ports.py b/ix-dev/community/firefly-iii/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/firefly-iii/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/firefly-iii/templates/library/base_v2_1_8/render.py b/ix-dev/community/firefly-iii/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/firefly-iii/templates/library/base_v2_1_8/render.py rename to ix-dev/community/firefly-iii/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/firefly-iii/templates/library/base_v2_1_8/resources.py b/ix-dev/community/firefly-iii/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/firefly-iii/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/firefly-iii/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/firefly-iii/templates/library/base_v2_1_8/restart.py b/ix-dev/community/firefly-iii/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/firefly-iii/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/firefly-iii/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/firefly-iii/templates/library/base_v2_1_8/storage.py b/ix-dev/community/firefly-iii/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/firefly-iii/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/firefly-iii/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/firefly-iii/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/firefly-iii/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/firefly-iii/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/firefly-iii/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/firefly-iii/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/firefly-iii/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/firefly-iii/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/firefly-iii/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/firefly-iii/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/firefly-iii/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/firefly-iii/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/firefly-iii/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/firefly-iii/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/firefly-iii/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/firefly-iii/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/firefly-iii/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/firefly-iii/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/firefly-iii/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/firefly-iii/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/firefly-iii/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/firefly-iii/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/firefly-iii/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/firefly-iii/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/firefly-iii/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/firefly-iii/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/firefly-iii/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/firefly-iii/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/firefly-iii/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/firefly-iii/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/firefly-iii/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/firefly-iii/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/firefly-iii/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/firefly-iii/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/firefly-iii/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/firefly-iii/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/firefly-iii/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/firefly-iii/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/firefly-iii/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/firefly-iii/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/firefly-iii/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/firefly-iii/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/firefly-iii/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/firefly-iii/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/firefly-iii/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/firefly-iii/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/firefly-iii/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/firefly-iii/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/firefly-iii/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/firefly-iii/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/firefly-iii/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/firefly-iii/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/firefly-iii/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/firefly-iii/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/firefly-iii/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/firefly-iii/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/firefly-iii/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/firefly-iii/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/firefly-iii/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/firefly-iii/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/firefly-iii/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/firefly-iii/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/firefly-iii/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/firefly-iii/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/firefly-iii/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/firefly-iii/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/firefly-iii/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/firefly-iii/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/firefly-iii/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/firefly-iii/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/firefly-iii/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/firefly-iii/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/firefly-iii/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/firefly-iii/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/firefly-iii/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/firefly-iii/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/firefly-iii/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/firefly-iii/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/firefly-iii/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/firefly-iii/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/firefly-iii/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/firefly-iii/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/firefly-iii/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/firefly-iii/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/firefly-iii/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/firefly-iii/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/firefly-iii/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/firefly-iii/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/firefly-iii/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/firefly-iii/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/firefly-iii/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/firefly-iii/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/firefly-iii/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/firefly-iii/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/firefly-iii/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/firefly-iii/templates/library/base_v2_1_8/validations.py b/ix-dev/community/firefly-iii/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/firefly-iii/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/firefly-iii/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/firefly-iii/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/firefly-iii/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/firefly-iii/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/firefly-iii/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/firefly-iii/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/firefly-iii/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/firefly-iii/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/firefly-iii/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/firefly-iii/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/firefly-iii/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/firefly-iii/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/firefly-iii/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/firefly-iii/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/firefly-iii/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/firefly-iii/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/firefly-iii/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/firefly-iii/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/firefly-iii/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/firefly-iii/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/firefly-iii/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/flame/app.yaml b/ix-dev/community/flame/app.yaml index 941c1275c1..f43cba74c4 100644 --- a/ix-dev/community/flame/app.yaml +++ b/ix-dev/community/flame/app.yaml @@ -14,8 +14,8 @@ host_mounts: [] icon: https://media.sys.truenas.net/apps/flame/icons/icon.png keywords: - startpage -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -34,4 +34,4 @@ sources: - https://github.com/pawelmalak/flame title: Flame train: community -version: 1.1.5 +version: 1.1.6 diff --git a/ix-dev/community/flame/templates/library/base_v2_1_8/container.py b/ix-dev/community/flame/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/flame/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/flame/templates/library/base_v2_1_8/ports.py b/ix-dev/community/flame/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/flame/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/flame/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/flame/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/flame/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/flame/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/flame/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/flame/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/flame/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/flame/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/flame/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/flame/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/flame/templates/library/base_v2_1_8/configs.py b/ix-dev/community/flame/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/flame/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/flame/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/flame/templates/library/base_v2_1_9/container.py b/ix-dev/community/flame/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/flame/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/flame/templates/library/base_v2_1_8/depends.py b/ix-dev/community/flame/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/flame/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/flame/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/flame/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/flame/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/flame/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/flame/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/flame/templates/library/base_v2_1_8/deps.py b/ix-dev/community/flame/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/flame/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/flame/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/flame/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/flame/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/flame/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/flame/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/flame/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/flame/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/flame/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/flame/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/flame/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/flame/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/flame/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/flame/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/flame/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/flame/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/flame/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/flame/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/flame/templates/library/base_v2_1_8/device.py b/ix-dev/community/flame/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/flame/templates/library/base_v2_1_8/device.py rename to ix-dev/community/flame/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/flame/templates/library/base_v2_1_8/devices.py b/ix-dev/community/flame/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/flame/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/flame/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/flame/templates/library/base_v2_1_8/dns.py b/ix-dev/community/flame/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/flame/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/flame/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/flame/templates/library/base_v2_1_8/environment.py b/ix-dev/community/flame/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/flame/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/flame/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/flame/templates/library/base_v2_1_8/error.py b/ix-dev/community/flame/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/flame/templates/library/base_v2_1_8/error.py rename to ix-dev/community/flame/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/flame/templates/library/base_v2_1_8/expose.py b/ix-dev/community/flame/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/flame/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/flame/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/flame/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/flame/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/flame/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/flame/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/flame/templates/library/base_v2_1_8/functions.py b/ix-dev/community/flame/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/flame/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/flame/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/flame/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/flame/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/flame/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/flame/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/flame/templates/library/base_v2_1_8/labels.py b/ix-dev/community/flame/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/flame/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/flame/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/flame/templates/library/base_v2_1_8/notes.py b/ix-dev/community/flame/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/flame/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/flame/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/flame/templates/library/base_v2_1_8/portal.py b/ix-dev/community/flame/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/flame/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/flame/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/flame/templates/library/base_v2_1_8/portals.py b/ix-dev/community/flame/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/flame/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/flame/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/flame/templates/library/base_v2_1_9/ports.py b/ix-dev/community/flame/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/flame/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/flame/templates/library/base_v2_1_8/render.py b/ix-dev/community/flame/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/flame/templates/library/base_v2_1_8/render.py rename to ix-dev/community/flame/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/flame/templates/library/base_v2_1_8/resources.py b/ix-dev/community/flame/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/flame/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/flame/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/flame/templates/library/base_v2_1_8/restart.py b/ix-dev/community/flame/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/flame/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/flame/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/flame/templates/library/base_v2_1_8/storage.py b/ix-dev/community/flame/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/flame/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/flame/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/flame/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/flame/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/flame/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/flame/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/flame/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/flame/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/flame/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/flame/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/flame/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/flame/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/flame/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/flame/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/flame/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/flame/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/flame/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/flame/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/flame/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/flame/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/flame/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/flame/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/flame/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/flame/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/flame/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/flame/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/flame/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/flame/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/flame/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/flame/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/flame/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/flame/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/flame/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/flame/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/flame/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/flame/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/flame/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/flame/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/flame/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/flame/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/flame/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/flame/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/flame/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/flame/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/flame/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/flame/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/flame/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/flame/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/flame/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/flame/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/flame/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/flame/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/flame/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/flame/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/flame/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/flame/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/flame/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/flame/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/flame/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/flame/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/flame/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/flame/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/flame/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/flame/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/flame/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/flame/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/flame/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/flame/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/flame/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/flame/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/flame/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/flame/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/flame/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/flame/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/flame/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/flame/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/flame/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/flame/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/flame/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/flame/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/flame/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/flame/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/flame/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/flame/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/flame/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/flame/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/flame/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/flame/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/flame/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/flame/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/flame/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/flame/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/flame/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/flame/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/flame/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/flame/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/flame/templates/library/base_v2_1_8/validations.py b/ix-dev/community/flame/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/flame/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/flame/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/flame/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/flame/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/flame/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/flame/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/flame/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/flame/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/flame/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/flame/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/flame/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/flame/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/flame/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/flame/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/flame/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/flame/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/flame/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/flame/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/flame/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/flame/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/flame/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/flame/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/flaresolverr/app.yaml b/ix-dev/community/flaresolverr/app.yaml index 985a74bcf4..6a8052a236 100644 --- a/ix-dev/community/flaresolverr/app.yaml +++ b/ix-dev/community/flaresolverr/app.yaml @@ -9,8 +9,8 @@ icon: https://media.sys.truenas.net/apps/flaresolverr/icons/icon.svg keywords: - networking - captcha -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -27,4 +27,4 @@ sources: - https://github.com/FlareSolverr/FlareSolverr title: FlareSolverr train: community -version: 1.0.14 +version: 1.0.15 diff --git a/ix-dev/community/flaresolverr/templates/library/base_v2_1_8/container.py b/ix-dev/community/flaresolverr/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/flaresolverr/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/flaresolverr/templates/library/base_v2_1_8/ports.py b/ix-dev/community/flaresolverr/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/flaresolverr/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/flaresolverr/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/flaresolverr/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/flaresolverr/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/flaresolverr/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/flaresolverr/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/flaresolverr/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/flaresolverr/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/flaresolverr/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/flaresolverr/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/flaresolverr/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/flaresolverr/templates/library/base_v2_1_8/configs.py b/ix-dev/community/flaresolverr/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/flaresolverr/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/flaresolverr/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/flaresolverr/templates/library/base_v2_1_9/container.py b/ix-dev/community/flaresolverr/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/flaresolverr/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/flaresolverr/templates/library/base_v2_1_8/depends.py b/ix-dev/community/flaresolverr/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/flaresolverr/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/flaresolverr/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/flaresolverr/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/flaresolverr/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/flaresolverr/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/flaresolverr/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/flaresolverr/templates/library/base_v2_1_8/deps.py b/ix-dev/community/flaresolverr/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/flaresolverr/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/flaresolverr/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/flaresolverr/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/flaresolverr/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/flaresolverr/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/flaresolverr/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/flaresolverr/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/flaresolverr/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/flaresolverr/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/flaresolverr/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/flaresolverr/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/flaresolverr/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/flaresolverr/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/flaresolverr/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/flaresolverr/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/flaresolverr/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/flaresolverr/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/flaresolverr/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/flaresolverr/templates/library/base_v2_1_8/device.py b/ix-dev/community/flaresolverr/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/flaresolverr/templates/library/base_v2_1_8/device.py rename to ix-dev/community/flaresolverr/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/flaresolverr/templates/library/base_v2_1_8/devices.py b/ix-dev/community/flaresolverr/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/flaresolverr/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/flaresolverr/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/flaresolverr/templates/library/base_v2_1_8/dns.py b/ix-dev/community/flaresolverr/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/flaresolverr/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/flaresolverr/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/flaresolverr/templates/library/base_v2_1_8/environment.py b/ix-dev/community/flaresolverr/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/flaresolverr/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/flaresolverr/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/flaresolverr/templates/library/base_v2_1_8/error.py b/ix-dev/community/flaresolverr/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/flaresolverr/templates/library/base_v2_1_8/error.py rename to ix-dev/community/flaresolverr/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/flaresolverr/templates/library/base_v2_1_8/expose.py b/ix-dev/community/flaresolverr/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/flaresolverr/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/flaresolverr/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/flaresolverr/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/flaresolverr/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/flaresolverr/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/flaresolverr/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/flaresolverr/templates/library/base_v2_1_8/functions.py b/ix-dev/community/flaresolverr/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/flaresolverr/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/flaresolverr/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/flaresolverr/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/flaresolverr/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/flaresolverr/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/flaresolverr/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/flaresolverr/templates/library/base_v2_1_8/labels.py b/ix-dev/community/flaresolverr/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/flaresolverr/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/flaresolverr/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/flaresolverr/templates/library/base_v2_1_8/notes.py b/ix-dev/community/flaresolverr/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/flaresolverr/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/flaresolverr/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/flaresolverr/templates/library/base_v2_1_8/portal.py b/ix-dev/community/flaresolverr/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/flaresolverr/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/flaresolverr/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/flaresolverr/templates/library/base_v2_1_8/portals.py b/ix-dev/community/flaresolverr/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/flaresolverr/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/flaresolverr/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/flaresolverr/templates/library/base_v2_1_9/ports.py b/ix-dev/community/flaresolverr/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/flaresolverr/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/flaresolverr/templates/library/base_v2_1_8/render.py b/ix-dev/community/flaresolverr/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/flaresolverr/templates/library/base_v2_1_8/render.py rename to ix-dev/community/flaresolverr/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/flaresolverr/templates/library/base_v2_1_8/resources.py b/ix-dev/community/flaresolverr/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/flaresolverr/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/flaresolverr/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/flaresolverr/templates/library/base_v2_1_8/restart.py b/ix-dev/community/flaresolverr/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/flaresolverr/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/flaresolverr/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/flaresolverr/templates/library/base_v2_1_8/storage.py b/ix-dev/community/flaresolverr/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/flaresolverr/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/flaresolverr/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/flaresolverr/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/flaresolverr/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/flaresolverr/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/flaresolverr/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/flaresolverr/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/flaresolverr/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/flaresolverr/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/flaresolverr/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/flaresolverr/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/flaresolverr/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/flaresolverr/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/flaresolverr/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/flaresolverr/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/flaresolverr/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/flaresolverr/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/flaresolverr/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/flaresolverr/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/flaresolverr/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/flaresolverr/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/flaresolverr/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/flaresolverr/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/flaresolverr/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/flaresolverr/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/flaresolverr/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/flaresolverr/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/flaresolverr/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/flaresolverr/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/flaresolverr/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/flaresolverr/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/flaresolverr/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/flaresolverr/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/flaresolverr/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/flaresolverr/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/flaresolverr/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/flaresolverr/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/flaresolverr/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/flaresolverr/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/flaresolverr/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/flaresolverr/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/flaresolverr/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/flaresolverr/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/flaresolverr/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/flaresolverr/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/flaresolverr/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/flaresolverr/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/flaresolverr/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/flaresolverr/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/flaresolverr/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/flaresolverr/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/flaresolverr/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/flaresolverr/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/flaresolverr/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/flaresolverr/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/flaresolverr/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/flaresolverr/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/flaresolverr/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/flaresolverr/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/flaresolverr/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/flaresolverr/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/flaresolverr/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/flaresolverr/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/flaresolverr/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/flaresolverr/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/flaresolverr/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/flaresolverr/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/flaresolverr/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/flaresolverr/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/flaresolverr/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/flaresolverr/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/flaresolverr/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/flaresolverr/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/flaresolverr/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/flaresolverr/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/flaresolverr/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/flaresolverr/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/flaresolverr/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/flaresolverr/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/flaresolverr/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/flaresolverr/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/flaresolverr/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/flaresolverr/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/flaresolverr/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/flaresolverr/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/flaresolverr/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/flaresolverr/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/flaresolverr/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/flaresolverr/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/flaresolverr/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/flaresolverr/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/flaresolverr/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/flaresolverr/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/flaresolverr/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/flaresolverr/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/flaresolverr/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/flaresolverr/templates/library/base_v2_1_8/validations.py b/ix-dev/community/flaresolverr/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/flaresolverr/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/flaresolverr/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/flaresolverr/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/flaresolverr/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/flaresolverr/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/flaresolverr/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/flaresolverr/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/flaresolverr/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/flaresolverr/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/flaresolverr/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/flaresolverr/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/flaresolverr/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/flaresolverr/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/flaresolverr/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/flaresolverr/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/flaresolverr/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/flaresolverr/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/flaresolverr/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/flaresolverr/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/flaresolverr/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/flaresolverr/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/flaresolverr/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/freshrss/app.yaml b/ix-dev/community/freshrss/app.yaml index 382b22b8d3..f37d301037 100644 --- a/ix-dev/community/freshrss/app.yaml +++ b/ix-dev/community/freshrss/app.yaml @@ -15,8 +15,8 @@ icon: https://media.sys.truenas.net/apps/freshrss/icons/icon.png keywords: - rss - news -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -41,4 +41,4 @@ sources: - https://hub.docker.com/r/freshrss/freshrss title: FreshRSS train: community -version: 1.3.5 +version: 1.3.6 diff --git a/ix-dev/community/freshrss/templates/library/base_v2_1_8/container.py b/ix-dev/community/freshrss/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/freshrss/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/freshrss/templates/library/base_v2_1_8/ports.py b/ix-dev/community/freshrss/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/freshrss/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/freshrss/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/freshrss/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/freshrss/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/freshrss/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/freshrss/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/freshrss/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/freshrss/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/freshrss/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/freshrss/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/freshrss/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/freshrss/templates/library/base_v2_1_8/configs.py b/ix-dev/community/freshrss/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/freshrss/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/freshrss/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/freshrss/templates/library/base_v2_1_9/container.py b/ix-dev/community/freshrss/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/freshrss/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/freshrss/templates/library/base_v2_1_8/depends.py b/ix-dev/community/freshrss/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/freshrss/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/freshrss/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/freshrss/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/freshrss/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/freshrss/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/freshrss/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/freshrss/templates/library/base_v2_1_8/deps.py b/ix-dev/community/freshrss/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/freshrss/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/freshrss/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/freshrss/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/freshrss/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/freshrss/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/freshrss/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/freshrss/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/freshrss/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/freshrss/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/freshrss/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/freshrss/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/freshrss/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/freshrss/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/freshrss/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/freshrss/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/freshrss/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/freshrss/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/freshrss/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/freshrss/templates/library/base_v2_1_8/device.py b/ix-dev/community/freshrss/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/freshrss/templates/library/base_v2_1_8/device.py rename to ix-dev/community/freshrss/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/freshrss/templates/library/base_v2_1_8/devices.py b/ix-dev/community/freshrss/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/freshrss/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/freshrss/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/freshrss/templates/library/base_v2_1_8/dns.py b/ix-dev/community/freshrss/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/freshrss/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/freshrss/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/freshrss/templates/library/base_v2_1_8/environment.py b/ix-dev/community/freshrss/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/freshrss/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/freshrss/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/freshrss/templates/library/base_v2_1_8/error.py b/ix-dev/community/freshrss/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/freshrss/templates/library/base_v2_1_8/error.py rename to ix-dev/community/freshrss/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/freshrss/templates/library/base_v2_1_8/expose.py b/ix-dev/community/freshrss/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/freshrss/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/freshrss/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/freshrss/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/freshrss/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/freshrss/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/freshrss/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/freshrss/templates/library/base_v2_1_8/functions.py b/ix-dev/community/freshrss/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/freshrss/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/freshrss/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/freshrss/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/freshrss/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/freshrss/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/freshrss/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/freshrss/templates/library/base_v2_1_8/labels.py b/ix-dev/community/freshrss/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/freshrss/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/freshrss/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/freshrss/templates/library/base_v2_1_8/notes.py b/ix-dev/community/freshrss/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/freshrss/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/freshrss/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/freshrss/templates/library/base_v2_1_8/portal.py b/ix-dev/community/freshrss/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/freshrss/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/freshrss/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/freshrss/templates/library/base_v2_1_8/portals.py b/ix-dev/community/freshrss/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/freshrss/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/freshrss/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/freshrss/templates/library/base_v2_1_9/ports.py b/ix-dev/community/freshrss/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/freshrss/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/freshrss/templates/library/base_v2_1_8/render.py b/ix-dev/community/freshrss/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/freshrss/templates/library/base_v2_1_8/render.py rename to ix-dev/community/freshrss/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/freshrss/templates/library/base_v2_1_8/resources.py b/ix-dev/community/freshrss/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/freshrss/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/freshrss/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/freshrss/templates/library/base_v2_1_8/restart.py b/ix-dev/community/freshrss/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/freshrss/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/freshrss/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/freshrss/templates/library/base_v2_1_8/storage.py b/ix-dev/community/freshrss/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/freshrss/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/freshrss/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/freshrss/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/freshrss/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/freshrss/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/freshrss/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/freshrss/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/freshrss/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/freshrss/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/freshrss/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/freshrss/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/freshrss/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/freshrss/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/freshrss/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/freshrss/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/freshrss/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/freshrss/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/freshrss/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/freshrss/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/freshrss/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/freshrss/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/freshrss/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/freshrss/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/freshrss/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/freshrss/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/freshrss/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/freshrss/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/freshrss/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/freshrss/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/freshrss/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/freshrss/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/freshrss/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/freshrss/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/freshrss/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/freshrss/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/freshrss/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/freshrss/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/freshrss/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/freshrss/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/freshrss/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/freshrss/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/freshrss/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/freshrss/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/freshrss/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/freshrss/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/freshrss/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/freshrss/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/freshrss/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/freshrss/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/freshrss/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/freshrss/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/freshrss/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/freshrss/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/freshrss/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/freshrss/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/freshrss/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/freshrss/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/freshrss/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/freshrss/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/freshrss/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/freshrss/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/freshrss/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/freshrss/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/freshrss/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/freshrss/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/freshrss/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/freshrss/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/freshrss/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/freshrss/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/freshrss/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/freshrss/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/freshrss/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/freshrss/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/freshrss/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/freshrss/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/freshrss/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/freshrss/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/freshrss/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/freshrss/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/freshrss/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/freshrss/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/freshrss/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/freshrss/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/freshrss/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/freshrss/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/freshrss/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/freshrss/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/freshrss/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/freshrss/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/freshrss/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/freshrss/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/freshrss/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/freshrss/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/freshrss/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/freshrss/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/freshrss/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/freshrss/templates/library/base_v2_1_8/validations.py b/ix-dev/community/freshrss/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/freshrss/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/freshrss/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/freshrss/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/freshrss/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/freshrss/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/freshrss/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/freshrss/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/freshrss/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/freshrss/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/freshrss/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/freshrss/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/freshrss/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/freshrss/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/freshrss/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/freshrss/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/freshrss/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/freshrss/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/freshrss/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/freshrss/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/freshrss/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/freshrss/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/freshrss/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/frigate/app.yaml b/ix-dev/community/frigate/app.yaml index 394f1654dc..da11ffb291 100644 --- a/ix-dev/community/frigate/app.yaml +++ b/ix-dev/community/frigate/app.yaml @@ -21,8 +21,8 @@ icon: https://media.sys.truenas.net/apps/frigate/icons/icon.svg keywords: - camera - nvr -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -42,4 +42,4 @@ sources: - https://github.com/blakeblackshear/frigate title: Frigate train: community -version: 1.1.12 +version: 1.1.13 diff --git a/ix-dev/community/frigate/templates/library/base_v2_1_8/container.py b/ix-dev/community/frigate/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/frigate/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/frigate/templates/library/base_v2_1_8/ports.py b/ix-dev/community/frigate/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/frigate/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/frigate/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/frigate/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/frigate/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/frigate/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/frigate/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/frigate/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/frigate/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/frigate/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/frigate/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/frigate/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/frigate/templates/library/base_v2_1_8/configs.py b/ix-dev/community/frigate/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/frigate/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/frigate/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/frigate/templates/library/base_v2_1_9/container.py b/ix-dev/community/frigate/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/frigate/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/frigate/templates/library/base_v2_1_8/depends.py b/ix-dev/community/frigate/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/frigate/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/frigate/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/frigate/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/frigate/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/frigate/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/frigate/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/frigate/templates/library/base_v2_1_8/deps.py b/ix-dev/community/frigate/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/frigate/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/frigate/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/frigate/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/frigate/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/frigate/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/frigate/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/frigate/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/frigate/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/frigate/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/frigate/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/frigate/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/frigate/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/frigate/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/frigate/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/frigate/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/frigate/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/frigate/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/frigate/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/frigate/templates/library/base_v2_1_8/device.py b/ix-dev/community/frigate/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/frigate/templates/library/base_v2_1_8/device.py rename to ix-dev/community/frigate/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/frigate/templates/library/base_v2_1_8/devices.py b/ix-dev/community/frigate/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/frigate/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/frigate/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/frigate/templates/library/base_v2_1_8/dns.py b/ix-dev/community/frigate/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/frigate/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/frigate/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/frigate/templates/library/base_v2_1_8/environment.py b/ix-dev/community/frigate/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/frigate/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/frigate/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/frigate/templates/library/base_v2_1_8/error.py b/ix-dev/community/frigate/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/frigate/templates/library/base_v2_1_8/error.py rename to ix-dev/community/frigate/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/frigate/templates/library/base_v2_1_8/expose.py b/ix-dev/community/frigate/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/frigate/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/frigate/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/frigate/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/frigate/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/frigate/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/frigate/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/frigate/templates/library/base_v2_1_8/functions.py b/ix-dev/community/frigate/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/frigate/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/frigate/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/frigate/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/frigate/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/frigate/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/frigate/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/frigate/templates/library/base_v2_1_8/labels.py b/ix-dev/community/frigate/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/frigate/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/frigate/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/frigate/templates/library/base_v2_1_8/notes.py b/ix-dev/community/frigate/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/frigate/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/frigate/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/frigate/templates/library/base_v2_1_8/portal.py b/ix-dev/community/frigate/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/frigate/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/frigate/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/frigate/templates/library/base_v2_1_8/portals.py b/ix-dev/community/frigate/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/frigate/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/frigate/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/frigate/templates/library/base_v2_1_9/ports.py b/ix-dev/community/frigate/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/frigate/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/frigate/templates/library/base_v2_1_8/render.py b/ix-dev/community/frigate/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/frigate/templates/library/base_v2_1_8/render.py rename to ix-dev/community/frigate/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/frigate/templates/library/base_v2_1_8/resources.py b/ix-dev/community/frigate/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/frigate/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/frigate/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/frigate/templates/library/base_v2_1_8/restart.py b/ix-dev/community/frigate/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/frigate/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/frigate/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/frigate/templates/library/base_v2_1_8/storage.py b/ix-dev/community/frigate/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/frigate/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/frigate/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/frigate/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/frigate/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/frigate/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/frigate/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/frigate/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/frigate/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/frigate/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/frigate/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/frigate/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/frigate/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/frigate/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/frigate/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/frigate/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/frigate/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/frigate/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/frigate/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/frigate/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/frigate/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/frigate/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/frigate/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/frigate/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/frigate/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/frigate/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/frigate/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/frigate/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/frigate/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/frigate/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/frigate/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/frigate/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/frigate/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/frigate/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/frigate/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/frigate/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/frigate/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/frigate/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/frigate/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/frigate/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/frigate/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/frigate/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/frigate/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/frigate/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/frigate/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/frigate/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/frigate/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/frigate/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/frigate/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/frigate/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/frigate/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/frigate/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/frigate/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/frigate/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/frigate/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/frigate/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/frigate/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/frigate/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/frigate/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/frigate/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/frigate/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/frigate/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/frigate/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/frigate/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/frigate/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/frigate/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/frigate/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/frigate/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/frigate/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/frigate/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/frigate/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/frigate/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/frigate/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/frigate/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/frigate/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/frigate/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/frigate/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/frigate/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/frigate/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/frigate/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/frigate/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/frigate/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/frigate/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/frigate/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/frigate/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/frigate/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/frigate/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/frigate/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/frigate/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/frigate/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/frigate/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/frigate/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/frigate/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/frigate/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/frigate/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/frigate/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/frigate/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/frigate/templates/library/base_v2_1_8/validations.py b/ix-dev/community/frigate/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/frigate/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/frigate/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/frigate/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/frigate/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/frigate/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/frigate/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/frigate/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/frigate/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/frigate/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/frigate/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/frigate/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/frigate/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/frigate/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/frigate/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/frigate/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/frigate/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/frigate/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/frigate/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/frigate/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/frigate/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/frigate/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/frigate/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/fscrawler/app.yaml b/ix-dev/community/fscrawler/app.yaml index 78b627ab88..6a3917d412 100644 --- a/ix-dev/community/fscrawler/app.yaml +++ b/ix-dev/community/fscrawler/app.yaml @@ -10,8 +10,8 @@ icon: https://media.sys.truenas.net/apps/fscrawler/icons/icon.svg keywords: - index - crawler -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -30,4 +30,4 @@ sources: - https://fscrawler.readthedocs.io/ title: FSCrawler train: community -version: 1.1.5 +version: 1.1.6 diff --git a/ix-dev/community/fscrawler/templates/library/base_v2_1_8/container.py b/ix-dev/community/fscrawler/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/fscrawler/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/fscrawler/templates/library/base_v2_1_8/ports.py b/ix-dev/community/fscrawler/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/fscrawler/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/fscrawler/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/fscrawler/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/fscrawler/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/fscrawler/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/fscrawler/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/fscrawler/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/fscrawler/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/fscrawler/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/fscrawler/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/fscrawler/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/fscrawler/templates/library/base_v2_1_8/configs.py b/ix-dev/community/fscrawler/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/fscrawler/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/fscrawler/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/fscrawler/templates/library/base_v2_1_9/container.py b/ix-dev/community/fscrawler/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/fscrawler/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/fscrawler/templates/library/base_v2_1_8/depends.py b/ix-dev/community/fscrawler/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/fscrawler/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/fscrawler/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/fscrawler/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/fscrawler/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/fscrawler/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/fscrawler/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/fscrawler/templates/library/base_v2_1_8/deps.py b/ix-dev/community/fscrawler/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/fscrawler/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/fscrawler/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/fscrawler/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/fscrawler/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/fscrawler/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/fscrawler/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/fscrawler/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/fscrawler/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/fscrawler/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/fscrawler/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/fscrawler/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/fscrawler/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/fscrawler/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/fscrawler/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/fscrawler/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/fscrawler/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/fscrawler/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/fscrawler/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/fscrawler/templates/library/base_v2_1_8/device.py b/ix-dev/community/fscrawler/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/fscrawler/templates/library/base_v2_1_8/device.py rename to ix-dev/community/fscrawler/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/fscrawler/templates/library/base_v2_1_8/devices.py b/ix-dev/community/fscrawler/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/fscrawler/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/fscrawler/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/fscrawler/templates/library/base_v2_1_8/dns.py b/ix-dev/community/fscrawler/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/fscrawler/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/fscrawler/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/fscrawler/templates/library/base_v2_1_8/environment.py b/ix-dev/community/fscrawler/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/fscrawler/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/fscrawler/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/fscrawler/templates/library/base_v2_1_8/error.py b/ix-dev/community/fscrawler/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/fscrawler/templates/library/base_v2_1_8/error.py rename to ix-dev/community/fscrawler/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/fscrawler/templates/library/base_v2_1_8/expose.py b/ix-dev/community/fscrawler/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/fscrawler/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/fscrawler/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/fscrawler/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/fscrawler/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/fscrawler/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/fscrawler/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/fscrawler/templates/library/base_v2_1_8/functions.py b/ix-dev/community/fscrawler/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/fscrawler/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/fscrawler/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/fscrawler/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/fscrawler/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/fscrawler/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/fscrawler/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/fscrawler/templates/library/base_v2_1_8/labels.py b/ix-dev/community/fscrawler/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/fscrawler/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/fscrawler/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/fscrawler/templates/library/base_v2_1_8/notes.py b/ix-dev/community/fscrawler/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/fscrawler/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/fscrawler/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/fscrawler/templates/library/base_v2_1_8/portal.py b/ix-dev/community/fscrawler/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/fscrawler/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/fscrawler/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/fscrawler/templates/library/base_v2_1_8/portals.py b/ix-dev/community/fscrawler/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/fscrawler/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/fscrawler/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/fscrawler/templates/library/base_v2_1_9/ports.py b/ix-dev/community/fscrawler/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/fscrawler/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/fscrawler/templates/library/base_v2_1_8/render.py b/ix-dev/community/fscrawler/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/fscrawler/templates/library/base_v2_1_8/render.py rename to ix-dev/community/fscrawler/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/fscrawler/templates/library/base_v2_1_8/resources.py b/ix-dev/community/fscrawler/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/fscrawler/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/fscrawler/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/fscrawler/templates/library/base_v2_1_8/restart.py b/ix-dev/community/fscrawler/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/fscrawler/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/fscrawler/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/fscrawler/templates/library/base_v2_1_8/storage.py b/ix-dev/community/fscrawler/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/fscrawler/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/fscrawler/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/fscrawler/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/fscrawler/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/fscrawler/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/fscrawler/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/fscrawler/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/fscrawler/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/fscrawler/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/fscrawler/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/fscrawler/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/fscrawler/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/fscrawler/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/fscrawler/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/fscrawler/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/fscrawler/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/fscrawler/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/fscrawler/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/fscrawler/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/fscrawler/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/fscrawler/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/fscrawler/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/fscrawler/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/fscrawler/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/fscrawler/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/fscrawler/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/fscrawler/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/fscrawler/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/fscrawler/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/fscrawler/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/fscrawler/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/fscrawler/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/fscrawler/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/fscrawler/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/fscrawler/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/fscrawler/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/fscrawler/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/fscrawler/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/fscrawler/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/fscrawler/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/fscrawler/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/fscrawler/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/fscrawler/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/fscrawler/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/fscrawler/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/fscrawler/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/fscrawler/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/fscrawler/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/fscrawler/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/fscrawler/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/fscrawler/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/fscrawler/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/fscrawler/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/fscrawler/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/fscrawler/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/fscrawler/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/fscrawler/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/fscrawler/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/fscrawler/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/fscrawler/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/fscrawler/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/fscrawler/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/fscrawler/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/fscrawler/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/fscrawler/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/fscrawler/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/fscrawler/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/fscrawler/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/fscrawler/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/fscrawler/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/fscrawler/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/fscrawler/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/fscrawler/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/fscrawler/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/fscrawler/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/fscrawler/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/fscrawler/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/fscrawler/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/fscrawler/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/fscrawler/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/fscrawler/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/fscrawler/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/fscrawler/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/fscrawler/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/fscrawler/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/fscrawler/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/fscrawler/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/fscrawler/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/fscrawler/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/fscrawler/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/fscrawler/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/fscrawler/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/fscrawler/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/fscrawler/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/fscrawler/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/fscrawler/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/fscrawler/templates/library/base_v2_1_8/validations.py b/ix-dev/community/fscrawler/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/fscrawler/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/fscrawler/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/fscrawler/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/fscrawler/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/fscrawler/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/fscrawler/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/fscrawler/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/fscrawler/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/fscrawler/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/fscrawler/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/fscrawler/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/fscrawler/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/fscrawler/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/fscrawler/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/fscrawler/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/fscrawler/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/fscrawler/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/fscrawler/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/fscrawler/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/fscrawler/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/fscrawler/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/fscrawler/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/gaseous-server/app.yaml b/ix-dev/community/gaseous-server/app.yaml index 44721bb9db..a4ac5888a5 100644 --- a/ix-dev/community/gaseous-server/app.yaml +++ b/ix-dev/community/gaseous-server/app.yaml @@ -16,8 +16,8 @@ icon: https://media.sys.truenas.net/apps/gaseous-server/icons/icon.png keywords: - games - emulation -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -41,4 +41,4 @@ sources: - https://github.com/gaseous-project/gaseous-server title: Gaseous Server train: community -version: 1.0.11 +version: 1.0.12 diff --git a/ix-dev/community/gaseous-server/templates/library/base_v2_1_8/container.py b/ix-dev/community/gaseous-server/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/gaseous-server/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/gaseous-server/templates/library/base_v2_1_8/ports.py b/ix-dev/community/gaseous-server/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/gaseous-server/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/gaseous-server/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/gaseous-server/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/gaseous-server/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/gaseous-server/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/gaseous-server/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/gaseous-server/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/gaseous-server/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/gaseous-server/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/gaseous-server/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/gaseous-server/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/gaseous-server/templates/library/base_v2_1_8/configs.py b/ix-dev/community/gaseous-server/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/gaseous-server/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/gaseous-server/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/gaseous-server/templates/library/base_v2_1_9/container.py b/ix-dev/community/gaseous-server/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/gaseous-server/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/gaseous-server/templates/library/base_v2_1_8/depends.py b/ix-dev/community/gaseous-server/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/gaseous-server/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/gaseous-server/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/gaseous-server/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/gaseous-server/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/gaseous-server/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/gaseous-server/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/gaseous-server/templates/library/base_v2_1_8/deps.py b/ix-dev/community/gaseous-server/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/gaseous-server/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/gaseous-server/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/gaseous-server/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/gaseous-server/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/gaseous-server/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/gaseous-server/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/gaseous-server/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/gaseous-server/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/gaseous-server/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/gaseous-server/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/gaseous-server/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/gaseous-server/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/gaseous-server/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/gaseous-server/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/gaseous-server/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/gaseous-server/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/gaseous-server/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/gaseous-server/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/gaseous-server/templates/library/base_v2_1_8/device.py b/ix-dev/community/gaseous-server/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/gaseous-server/templates/library/base_v2_1_8/device.py rename to ix-dev/community/gaseous-server/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/gaseous-server/templates/library/base_v2_1_8/devices.py b/ix-dev/community/gaseous-server/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/gaseous-server/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/gaseous-server/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/gaseous-server/templates/library/base_v2_1_8/dns.py b/ix-dev/community/gaseous-server/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/gaseous-server/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/gaseous-server/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/gaseous-server/templates/library/base_v2_1_8/environment.py b/ix-dev/community/gaseous-server/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/gaseous-server/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/gaseous-server/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/gaseous-server/templates/library/base_v2_1_8/error.py b/ix-dev/community/gaseous-server/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/gaseous-server/templates/library/base_v2_1_8/error.py rename to ix-dev/community/gaseous-server/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/gaseous-server/templates/library/base_v2_1_8/expose.py b/ix-dev/community/gaseous-server/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/gaseous-server/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/gaseous-server/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/gaseous-server/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/gaseous-server/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/gaseous-server/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/gaseous-server/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/gaseous-server/templates/library/base_v2_1_8/functions.py b/ix-dev/community/gaseous-server/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/gaseous-server/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/gaseous-server/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/gaseous-server/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/gaseous-server/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/gaseous-server/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/gaseous-server/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/gaseous-server/templates/library/base_v2_1_8/labels.py b/ix-dev/community/gaseous-server/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/gaseous-server/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/gaseous-server/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/gaseous-server/templates/library/base_v2_1_8/notes.py b/ix-dev/community/gaseous-server/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/gaseous-server/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/gaseous-server/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/gaseous-server/templates/library/base_v2_1_8/portal.py b/ix-dev/community/gaseous-server/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/gaseous-server/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/gaseous-server/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/gaseous-server/templates/library/base_v2_1_8/portals.py b/ix-dev/community/gaseous-server/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/gaseous-server/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/gaseous-server/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/gaseous-server/templates/library/base_v2_1_9/ports.py b/ix-dev/community/gaseous-server/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/gaseous-server/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/gaseous-server/templates/library/base_v2_1_8/render.py b/ix-dev/community/gaseous-server/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/gaseous-server/templates/library/base_v2_1_8/render.py rename to ix-dev/community/gaseous-server/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/gaseous-server/templates/library/base_v2_1_8/resources.py b/ix-dev/community/gaseous-server/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/gaseous-server/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/gaseous-server/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/gaseous-server/templates/library/base_v2_1_8/restart.py b/ix-dev/community/gaseous-server/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/gaseous-server/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/gaseous-server/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/gaseous-server/templates/library/base_v2_1_8/storage.py b/ix-dev/community/gaseous-server/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/gaseous-server/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/gaseous-server/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/gaseous-server/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/gaseous-server/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/gaseous-server/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/gaseous-server/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/gaseous-server/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/gaseous-server/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/gaseous-server/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/gaseous-server/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/gaseous-server/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/gaseous-server/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/gaseous-server/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/gaseous-server/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/gaseous-server/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/gaseous-server/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/gaseous-server/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/gaseous-server/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/gaseous-server/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/gaseous-server/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/gaseous-server/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/gaseous-server/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/gaseous-server/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/gaseous-server/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/gaseous-server/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/gaseous-server/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/gaseous-server/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/gaseous-server/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/gaseous-server/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/gaseous-server/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/gaseous-server/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/gaseous-server/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/gaseous-server/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/gaseous-server/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/gaseous-server/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/gaseous-server/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/gaseous-server/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/gaseous-server/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/gaseous-server/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/gaseous-server/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/gaseous-server/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/gaseous-server/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/gaseous-server/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/gaseous-server/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/gaseous-server/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/gaseous-server/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/gaseous-server/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/gaseous-server/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/gaseous-server/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/gaseous-server/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/gaseous-server/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/gaseous-server/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/gaseous-server/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/gaseous-server/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/gaseous-server/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/gaseous-server/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/gaseous-server/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/gaseous-server/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/gaseous-server/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/gaseous-server/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/gaseous-server/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/gaseous-server/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/gaseous-server/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/gaseous-server/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/gaseous-server/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/gaseous-server/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/gaseous-server/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/gaseous-server/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/gaseous-server/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/gaseous-server/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/gaseous-server/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/gaseous-server/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/gaseous-server/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/gaseous-server/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/gaseous-server/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/gaseous-server/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/gaseous-server/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/gaseous-server/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/gaseous-server/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/gaseous-server/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/gaseous-server/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/gaseous-server/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/gaseous-server/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/gaseous-server/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/gaseous-server/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/gaseous-server/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/gaseous-server/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/gaseous-server/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/gaseous-server/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/gaseous-server/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/gaseous-server/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/gaseous-server/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/gaseous-server/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/gaseous-server/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/gaseous-server/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/gaseous-server/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/gaseous-server/templates/library/base_v2_1_8/validations.py b/ix-dev/community/gaseous-server/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/gaseous-server/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/gaseous-server/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/gaseous-server/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/gaseous-server/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/gaseous-server/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/gaseous-server/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/gaseous-server/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/gaseous-server/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/gaseous-server/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/gaseous-server/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/gaseous-server/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/gaseous-server/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/gaseous-server/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/gaseous-server/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/gaseous-server/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/gaseous-server/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/gaseous-server/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/gaseous-server/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/gaseous-server/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/gaseous-server/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/gaseous-server/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/gaseous-server/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/gitea/app.yaml b/ix-dev/community/gitea/app.yaml index 21f461bc48..5588d234ea 100644 --- a/ix-dev/community/gitea/app.yaml +++ b/ix-dev/community/gitea/app.yaml @@ -10,8 +10,8 @@ keywords: - git - gitea - source control -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -38,4 +38,4 @@ sources: - https://docs.gitea.io/en-us/install-with-docker-rootless title: Gitea train: community -version: 1.2.8 +version: 1.2.9 diff --git a/ix-dev/community/gitea/templates/library/base_v2_1_8/container.py b/ix-dev/community/gitea/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/gitea/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/gitea/templates/library/base_v2_1_8/ports.py b/ix-dev/community/gitea/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/gitea/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/gitea/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/gitea/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/gitea/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/gitea/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/gitea/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/gitea/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/gitea/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/gitea/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/gitea/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/gitea/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/gitea/templates/library/base_v2_1_8/configs.py b/ix-dev/community/gitea/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/gitea/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/gitea/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/gitea/templates/library/base_v2_1_9/container.py b/ix-dev/community/gitea/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/gitea/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/gitea/templates/library/base_v2_1_8/depends.py b/ix-dev/community/gitea/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/gitea/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/gitea/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/gitea/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/gitea/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/gitea/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/gitea/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/gitea/templates/library/base_v2_1_8/deps.py b/ix-dev/community/gitea/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/gitea/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/gitea/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/gitea/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/gitea/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/gitea/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/gitea/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/gitea/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/gitea/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/gitea/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/gitea/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/gitea/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/gitea/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/gitea/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/gitea/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/gitea/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/gitea/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/gitea/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/gitea/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/gitea/templates/library/base_v2_1_8/device.py b/ix-dev/community/gitea/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/gitea/templates/library/base_v2_1_8/device.py rename to ix-dev/community/gitea/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/gitea/templates/library/base_v2_1_8/devices.py b/ix-dev/community/gitea/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/gitea/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/gitea/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/gitea/templates/library/base_v2_1_8/dns.py b/ix-dev/community/gitea/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/gitea/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/gitea/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/gitea/templates/library/base_v2_1_8/environment.py b/ix-dev/community/gitea/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/gitea/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/gitea/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/gitea/templates/library/base_v2_1_8/error.py b/ix-dev/community/gitea/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/gitea/templates/library/base_v2_1_8/error.py rename to ix-dev/community/gitea/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/gitea/templates/library/base_v2_1_8/expose.py b/ix-dev/community/gitea/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/gitea/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/gitea/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/gitea/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/gitea/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/gitea/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/gitea/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/gitea/templates/library/base_v2_1_8/functions.py b/ix-dev/community/gitea/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/gitea/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/gitea/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/gitea/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/gitea/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/gitea/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/gitea/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/gitea/templates/library/base_v2_1_8/labels.py b/ix-dev/community/gitea/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/gitea/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/gitea/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/gitea/templates/library/base_v2_1_8/notes.py b/ix-dev/community/gitea/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/gitea/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/gitea/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/gitea/templates/library/base_v2_1_8/portal.py b/ix-dev/community/gitea/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/gitea/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/gitea/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/gitea/templates/library/base_v2_1_8/portals.py b/ix-dev/community/gitea/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/gitea/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/gitea/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/gitea/templates/library/base_v2_1_9/ports.py b/ix-dev/community/gitea/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/gitea/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/gitea/templates/library/base_v2_1_8/render.py b/ix-dev/community/gitea/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/gitea/templates/library/base_v2_1_8/render.py rename to ix-dev/community/gitea/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/gitea/templates/library/base_v2_1_8/resources.py b/ix-dev/community/gitea/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/gitea/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/gitea/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/gitea/templates/library/base_v2_1_8/restart.py b/ix-dev/community/gitea/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/gitea/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/gitea/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/gitea/templates/library/base_v2_1_8/storage.py b/ix-dev/community/gitea/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/gitea/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/gitea/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/gitea/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/gitea/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/gitea/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/gitea/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/gitea/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/gitea/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/gitea/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/gitea/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/gitea/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/gitea/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/gitea/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/gitea/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/gitea/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/gitea/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/gitea/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/gitea/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/gitea/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/gitea/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/gitea/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/gitea/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/gitea/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/gitea/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/gitea/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/gitea/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/gitea/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/gitea/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/gitea/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/gitea/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/gitea/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/gitea/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/gitea/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/gitea/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/gitea/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/gitea/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/gitea/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/gitea/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/gitea/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/gitea/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/gitea/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/gitea/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/gitea/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/gitea/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/gitea/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/gitea/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/gitea/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/gitea/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/gitea/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/gitea/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/gitea/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/gitea/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/gitea/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/gitea/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/gitea/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/gitea/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/gitea/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/gitea/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/gitea/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/gitea/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/gitea/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/gitea/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/gitea/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/gitea/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/gitea/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/gitea/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/gitea/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/gitea/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/gitea/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/gitea/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/gitea/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/gitea/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/gitea/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/gitea/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/gitea/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/gitea/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/gitea/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/gitea/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/gitea/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/gitea/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/gitea/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/gitea/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/gitea/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/gitea/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/gitea/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/gitea/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/gitea/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/gitea/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/gitea/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/gitea/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/gitea/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/gitea/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/gitea/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/gitea/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/gitea/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/gitea/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/gitea/templates/library/base_v2_1_8/validations.py b/ix-dev/community/gitea/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/gitea/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/gitea/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/gitea/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/gitea/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/gitea/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/gitea/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/gitea/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/gitea/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/gitea/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/gitea/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/gitea/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/gitea/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/gitea/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/gitea/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/gitea/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/gitea/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/gitea/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/gitea/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/gitea/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/gitea/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/gitea/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/gitea/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/grafana/app.yaml b/ix-dev/community/grafana/app.yaml index 4d058edcbf..3ec1e7dfa8 100644 --- a/ix-dev/community/grafana/app.yaml +++ b/ix-dev/community/grafana/app.yaml @@ -12,8 +12,8 @@ keywords: - monitoring - metrics - dashboards -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -34,4 +34,4 @@ sources: - https://github.com/grafana title: Grafana train: community -version: 1.2.7 +version: 1.2.8 diff --git a/ix-dev/community/grafana/templates/library/base_v2_1_8/container.py b/ix-dev/community/grafana/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/grafana/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/grafana/templates/library/base_v2_1_8/ports.py b/ix-dev/community/grafana/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/grafana/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/grafana/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/grafana/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/grafana/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/grafana/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/grafana/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/grafana/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/grafana/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/grafana/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/grafana/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/grafana/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/grafana/templates/library/base_v2_1_8/configs.py b/ix-dev/community/grafana/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/grafana/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/grafana/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/grafana/templates/library/base_v2_1_9/container.py b/ix-dev/community/grafana/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/grafana/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/grafana/templates/library/base_v2_1_8/depends.py b/ix-dev/community/grafana/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/grafana/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/grafana/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/grafana/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/grafana/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/grafana/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/grafana/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/grafana/templates/library/base_v2_1_8/deps.py b/ix-dev/community/grafana/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/grafana/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/grafana/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/grafana/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/grafana/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/grafana/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/grafana/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/grafana/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/grafana/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/grafana/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/grafana/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/grafana/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/grafana/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/grafana/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/grafana/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/grafana/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/grafana/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/grafana/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/grafana/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/grafana/templates/library/base_v2_1_8/device.py b/ix-dev/community/grafana/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/grafana/templates/library/base_v2_1_8/device.py rename to ix-dev/community/grafana/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/grafana/templates/library/base_v2_1_8/devices.py b/ix-dev/community/grafana/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/grafana/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/grafana/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/grafana/templates/library/base_v2_1_8/dns.py b/ix-dev/community/grafana/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/grafana/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/grafana/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/grafana/templates/library/base_v2_1_8/environment.py b/ix-dev/community/grafana/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/grafana/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/grafana/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/grafana/templates/library/base_v2_1_8/error.py b/ix-dev/community/grafana/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/grafana/templates/library/base_v2_1_8/error.py rename to ix-dev/community/grafana/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/grafana/templates/library/base_v2_1_8/expose.py b/ix-dev/community/grafana/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/grafana/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/grafana/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/grafana/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/grafana/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/grafana/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/grafana/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/grafana/templates/library/base_v2_1_8/functions.py b/ix-dev/community/grafana/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/grafana/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/grafana/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/grafana/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/grafana/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/grafana/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/grafana/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/grafana/templates/library/base_v2_1_8/labels.py b/ix-dev/community/grafana/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/grafana/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/grafana/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/grafana/templates/library/base_v2_1_8/notes.py b/ix-dev/community/grafana/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/grafana/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/grafana/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/grafana/templates/library/base_v2_1_8/portal.py b/ix-dev/community/grafana/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/grafana/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/grafana/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/grafana/templates/library/base_v2_1_8/portals.py b/ix-dev/community/grafana/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/grafana/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/grafana/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/grafana/templates/library/base_v2_1_9/ports.py b/ix-dev/community/grafana/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/grafana/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/grafana/templates/library/base_v2_1_8/render.py b/ix-dev/community/grafana/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/grafana/templates/library/base_v2_1_8/render.py rename to ix-dev/community/grafana/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/grafana/templates/library/base_v2_1_8/resources.py b/ix-dev/community/grafana/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/grafana/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/grafana/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/grafana/templates/library/base_v2_1_8/restart.py b/ix-dev/community/grafana/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/grafana/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/grafana/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/grafana/templates/library/base_v2_1_8/storage.py b/ix-dev/community/grafana/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/grafana/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/grafana/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/grafana/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/grafana/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/grafana/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/grafana/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/grafana/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/grafana/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/grafana/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/grafana/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/grafana/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/grafana/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/grafana/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/grafana/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/grafana/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/grafana/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/grafana/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/grafana/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/grafana/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/grafana/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/grafana/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/grafana/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/grafana/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/grafana/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/grafana/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/grafana/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/grafana/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/grafana/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/grafana/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/grafana/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/grafana/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/grafana/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/grafana/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/grafana/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/grafana/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/grafana/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/grafana/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/grafana/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/grafana/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/grafana/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/grafana/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/grafana/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/grafana/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/grafana/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/grafana/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/grafana/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/grafana/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/grafana/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/grafana/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/grafana/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/grafana/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/grafana/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/grafana/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/grafana/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/grafana/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/grafana/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/grafana/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/grafana/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/grafana/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/grafana/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/grafana/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/grafana/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/grafana/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/grafana/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/grafana/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/grafana/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/grafana/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/grafana/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/grafana/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/grafana/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/grafana/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/grafana/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/grafana/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/grafana/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/grafana/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/grafana/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/grafana/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/grafana/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/grafana/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/grafana/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/grafana/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/grafana/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/grafana/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/grafana/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/grafana/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/grafana/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/grafana/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/grafana/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/grafana/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/grafana/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/grafana/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/grafana/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/grafana/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/grafana/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/grafana/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/grafana/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/grafana/templates/library/base_v2_1_8/validations.py b/ix-dev/community/grafana/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/grafana/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/grafana/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/grafana/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/grafana/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/grafana/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/grafana/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/grafana/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/grafana/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/grafana/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/grafana/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/grafana/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/grafana/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/grafana/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/grafana/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/grafana/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/grafana/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/grafana/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/grafana/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/grafana/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/grafana/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/grafana/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/grafana/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/handbrake/app.yaml b/ix-dev/community/handbrake/app.yaml index 6831f95f3d..9178a62149 100644 --- a/ix-dev/community/handbrake/app.yaml +++ b/ix-dev/community/handbrake/app.yaml @@ -28,8 +28,8 @@ keywords: - media - video - transcoder -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -47,4 +47,4 @@ sources: - https://hub.docker.com/r/jlesage/handbrake title: Handbrake train: community -version: 2.1.6 +version: 2.1.7 diff --git a/ix-dev/community/handbrake/templates/library/base_v2_1_8/container.py b/ix-dev/community/handbrake/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/handbrake/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/handbrake/templates/library/base_v2_1_8/ports.py b/ix-dev/community/handbrake/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/handbrake/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/handbrake/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/handbrake/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/handbrake/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/handbrake/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/handbrake/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/handbrake/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/handbrake/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/handbrake/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/handbrake/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/handbrake/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/handbrake/templates/library/base_v2_1_8/configs.py b/ix-dev/community/handbrake/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/handbrake/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/handbrake/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/handbrake/templates/library/base_v2_1_9/container.py b/ix-dev/community/handbrake/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/handbrake/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/handbrake/templates/library/base_v2_1_8/depends.py b/ix-dev/community/handbrake/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/handbrake/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/handbrake/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/handbrake/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/handbrake/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/handbrake/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/handbrake/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/handbrake/templates/library/base_v2_1_8/deps.py b/ix-dev/community/handbrake/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/handbrake/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/handbrake/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/handbrake/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/handbrake/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/handbrake/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/handbrake/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/handbrake/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/handbrake/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/handbrake/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/handbrake/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/handbrake/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/handbrake/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/handbrake/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/handbrake/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/handbrake/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/handbrake/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/handbrake/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/handbrake/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/handbrake/templates/library/base_v2_1_8/device.py b/ix-dev/community/handbrake/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/handbrake/templates/library/base_v2_1_8/device.py rename to ix-dev/community/handbrake/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/handbrake/templates/library/base_v2_1_8/devices.py b/ix-dev/community/handbrake/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/handbrake/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/handbrake/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/handbrake/templates/library/base_v2_1_8/dns.py b/ix-dev/community/handbrake/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/handbrake/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/handbrake/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/handbrake/templates/library/base_v2_1_8/environment.py b/ix-dev/community/handbrake/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/handbrake/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/handbrake/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/handbrake/templates/library/base_v2_1_8/error.py b/ix-dev/community/handbrake/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/handbrake/templates/library/base_v2_1_8/error.py rename to ix-dev/community/handbrake/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/handbrake/templates/library/base_v2_1_8/expose.py b/ix-dev/community/handbrake/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/handbrake/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/handbrake/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/handbrake/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/handbrake/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/handbrake/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/handbrake/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/handbrake/templates/library/base_v2_1_8/functions.py b/ix-dev/community/handbrake/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/handbrake/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/handbrake/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/handbrake/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/handbrake/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/handbrake/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/handbrake/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/handbrake/templates/library/base_v2_1_8/labels.py b/ix-dev/community/handbrake/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/handbrake/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/handbrake/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/handbrake/templates/library/base_v2_1_8/notes.py b/ix-dev/community/handbrake/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/handbrake/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/handbrake/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/handbrake/templates/library/base_v2_1_8/portal.py b/ix-dev/community/handbrake/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/handbrake/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/handbrake/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/handbrake/templates/library/base_v2_1_8/portals.py b/ix-dev/community/handbrake/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/handbrake/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/handbrake/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/handbrake/templates/library/base_v2_1_9/ports.py b/ix-dev/community/handbrake/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/handbrake/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/handbrake/templates/library/base_v2_1_8/render.py b/ix-dev/community/handbrake/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/handbrake/templates/library/base_v2_1_8/render.py rename to ix-dev/community/handbrake/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/handbrake/templates/library/base_v2_1_8/resources.py b/ix-dev/community/handbrake/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/handbrake/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/handbrake/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/handbrake/templates/library/base_v2_1_8/restart.py b/ix-dev/community/handbrake/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/handbrake/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/handbrake/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/handbrake/templates/library/base_v2_1_8/storage.py b/ix-dev/community/handbrake/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/handbrake/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/handbrake/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/handbrake/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/handbrake/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/handbrake/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/handbrake/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/handbrake/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/handbrake/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/handbrake/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/handbrake/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/handbrake/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/handbrake/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/handbrake/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/handbrake/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/handbrake/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/handbrake/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/handbrake/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/handbrake/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/handbrake/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/handbrake/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/handbrake/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/handbrake/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/handbrake/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/handbrake/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/handbrake/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/handbrake/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/handbrake/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/handbrake/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/handbrake/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/handbrake/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/handbrake/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/handbrake/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/handbrake/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/handbrake/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/handbrake/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/handbrake/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/handbrake/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/handbrake/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/handbrake/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/handbrake/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/handbrake/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/handbrake/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/handbrake/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/handbrake/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/handbrake/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/handbrake/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/handbrake/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/handbrake/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/handbrake/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/handbrake/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/handbrake/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/handbrake/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/handbrake/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/handbrake/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/handbrake/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/handbrake/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/handbrake/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/handbrake/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/handbrake/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/handbrake/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/handbrake/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/handbrake/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/handbrake/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/handbrake/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/handbrake/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/handbrake/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/handbrake/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/handbrake/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/handbrake/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/handbrake/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/handbrake/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/handbrake/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/handbrake/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/handbrake/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/handbrake/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/handbrake/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/handbrake/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/handbrake/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/handbrake/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/handbrake/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/handbrake/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/handbrake/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/handbrake/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/handbrake/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/handbrake/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/handbrake/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/handbrake/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/handbrake/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/handbrake/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/handbrake/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/handbrake/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/handbrake/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/handbrake/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/handbrake/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/handbrake/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/handbrake/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/handbrake/templates/library/base_v2_1_8/validations.py b/ix-dev/community/handbrake/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/handbrake/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/handbrake/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/handbrake/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/handbrake/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/handbrake/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/handbrake/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/handbrake/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/handbrake/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/handbrake/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/handbrake/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/handbrake/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/handbrake/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/handbrake/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/handbrake/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/handbrake/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/handbrake/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/handbrake/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/handbrake/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/handbrake/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/handbrake/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/handbrake/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/handbrake/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/homarr/app.yaml b/ix-dev/community/homarr/app.yaml index 6afb1e1982..7d3076cdd5 100644 --- a/ix-dev/community/homarr/app.yaml +++ b/ix-dev/community/homarr/app.yaml @@ -9,8 +9,8 @@ host_mounts: [] icon: https://media.sys.truenas.net/apps/homarr/icons/icon.svg keywords: - dashboard -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -31,4 +31,4 @@ sources: - https://github.com/ajnart/homarr title: Homarr train: community -version: 1.1.8 +version: 1.1.9 diff --git a/ix-dev/community/homarr/templates/library/base_v2_1_8/container.py b/ix-dev/community/homarr/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/homarr/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/homarr/templates/library/base_v2_1_8/ports.py b/ix-dev/community/homarr/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/homarr/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/homarr/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/homarr/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/homarr/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/homarr/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/homarr/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/homarr/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/homarr/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/homarr/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/homarr/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/homarr/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/homarr/templates/library/base_v2_1_8/configs.py b/ix-dev/community/homarr/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/homarr/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/homarr/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/homarr/templates/library/base_v2_1_9/container.py b/ix-dev/community/homarr/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/homarr/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/homarr/templates/library/base_v2_1_8/depends.py b/ix-dev/community/homarr/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/homarr/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/homarr/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/homarr/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/homarr/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/homarr/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/homarr/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/homarr/templates/library/base_v2_1_8/deps.py b/ix-dev/community/homarr/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/homarr/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/homarr/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/homarr/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/homarr/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/homarr/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/homarr/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/homarr/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/homarr/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/homarr/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/homarr/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/homarr/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/homarr/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/homarr/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/homarr/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/homarr/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/homarr/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/homarr/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/homarr/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/homarr/templates/library/base_v2_1_8/device.py b/ix-dev/community/homarr/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/homarr/templates/library/base_v2_1_8/device.py rename to ix-dev/community/homarr/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/homarr/templates/library/base_v2_1_8/devices.py b/ix-dev/community/homarr/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/homarr/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/homarr/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/homarr/templates/library/base_v2_1_8/dns.py b/ix-dev/community/homarr/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/homarr/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/homarr/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/homarr/templates/library/base_v2_1_8/environment.py b/ix-dev/community/homarr/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/homarr/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/homarr/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/homarr/templates/library/base_v2_1_8/error.py b/ix-dev/community/homarr/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/homarr/templates/library/base_v2_1_8/error.py rename to ix-dev/community/homarr/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/homarr/templates/library/base_v2_1_8/expose.py b/ix-dev/community/homarr/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/homarr/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/homarr/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/homarr/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/homarr/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/homarr/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/homarr/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/homarr/templates/library/base_v2_1_8/functions.py b/ix-dev/community/homarr/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/homarr/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/homarr/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/homarr/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/homarr/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/homarr/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/homarr/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/homarr/templates/library/base_v2_1_8/labels.py b/ix-dev/community/homarr/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/homarr/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/homarr/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/homarr/templates/library/base_v2_1_8/notes.py b/ix-dev/community/homarr/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/homarr/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/homarr/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/homarr/templates/library/base_v2_1_8/portal.py b/ix-dev/community/homarr/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/homarr/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/homarr/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/homarr/templates/library/base_v2_1_8/portals.py b/ix-dev/community/homarr/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/homarr/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/homarr/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/homarr/templates/library/base_v2_1_9/ports.py b/ix-dev/community/homarr/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/homarr/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/homarr/templates/library/base_v2_1_8/render.py b/ix-dev/community/homarr/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/homarr/templates/library/base_v2_1_8/render.py rename to ix-dev/community/homarr/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/homarr/templates/library/base_v2_1_8/resources.py b/ix-dev/community/homarr/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/homarr/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/homarr/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/homarr/templates/library/base_v2_1_8/restart.py b/ix-dev/community/homarr/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/homarr/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/homarr/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/homarr/templates/library/base_v2_1_8/storage.py b/ix-dev/community/homarr/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/homarr/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/homarr/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/homarr/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/homarr/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/homarr/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/homarr/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/homarr/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/homarr/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/homarr/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/homarr/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/homarr/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/homarr/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/homarr/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/homarr/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/homarr/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/homarr/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/homarr/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/homarr/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/homarr/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/homarr/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/homarr/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/homarr/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/homarr/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/homarr/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/homarr/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/homarr/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/homarr/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/homarr/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/homarr/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/homarr/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/homarr/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/homarr/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/homarr/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/homarr/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/homarr/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/homarr/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/homarr/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/homarr/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/homarr/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/homarr/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/homarr/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/homarr/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/homarr/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/homarr/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/homarr/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/homarr/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/homarr/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/homarr/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/homarr/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/homarr/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/homarr/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/homarr/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/homarr/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/homarr/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/homarr/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/homarr/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/homarr/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/homarr/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/homarr/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/homarr/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/homarr/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/homarr/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/homarr/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/homarr/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/homarr/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/homarr/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/homarr/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/homarr/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/homarr/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/homarr/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/homarr/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/homarr/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/homarr/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/homarr/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/homarr/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/homarr/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/homarr/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/homarr/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/homarr/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/homarr/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/homarr/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/homarr/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/homarr/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/homarr/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/homarr/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/homarr/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/homarr/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/homarr/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/homarr/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/homarr/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/homarr/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/homarr/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/homarr/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/homarr/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/homarr/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/homarr/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/homarr/templates/library/base_v2_1_8/validations.py b/ix-dev/community/homarr/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/homarr/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/homarr/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/homarr/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/homarr/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/homarr/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/homarr/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/homarr/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/homarr/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/homarr/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/homarr/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/homarr/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/homarr/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/homarr/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/homarr/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/homarr/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/homarr/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/homarr/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/homarr/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/homarr/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/homarr/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/homarr/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/homarr/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/homepage/app.yaml b/ix-dev/community/homepage/app.yaml index 254e613166..20fc3e2cbb 100644 --- a/ix-dev/community/homepage/app.yaml +++ b/ix-dev/community/homepage/app.yaml @@ -8,8 +8,8 @@ host_mounts: [] icon: https://media.sys.truenas.net/apps/homepage/icons/icon.png keywords: - dashboard -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -33,4 +33,4 @@ sources: - https://github.com/benphelps/homepage title: Homepage train: community -version: 1.1.12 +version: 1.1.13 diff --git a/ix-dev/community/homepage/templates/library/base_v2_1_8/container.py b/ix-dev/community/homepage/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/homepage/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/homepage/templates/library/base_v2_1_8/ports.py b/ix-dev/community/homepage/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/homepage/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/homepage/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/homepage/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/homepage/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/homepage/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/homepage/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/homepage/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/homepage/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/homepage/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/homepage/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/homepage/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/homepage/templates/library/base_v2_1_8/configs.py b/ix-dev/community/homepage/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/homepage/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/homepage/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/homepage/templates/library/base_v2_1_9/container.py b/ix-dev/community/homepage/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/homepage/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/homepage/templates/library/base_v2_1_8/depends.py b/ix-dev/community/homepage/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/homepage/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/homepage/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/homepage/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/homepage/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/homepage/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/homepage/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/homepage/templates/library/base_v2_1_8/deps.py b/ix-dev/community/homepage/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/homepage/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/homepage/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/homepage/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/homepage/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/homepage/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/homepage/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/homepage/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/homepage/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/homepage/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/homepage/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/homepage/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/homepage/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/homepage/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/homepage/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/homepage/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/homepage/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/homepage/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/homepage/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/homepage/templates/library/base_v2_1_8/device.py b/ix-dev/community/homepage/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/homepage/templates/library/base_v2_1_8/device.py rename to ix-dev/community/homepage/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/homepage/templates/library/base_v2_1_8/devices.py b/ix-dev/community/homepage/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/homepage/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/homepage/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/homepage/templates/library/base_v2_1_8/dns.py b/ix-dev/community/homepage/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/homepage/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/homepage/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/homepage/templates/library/base_v2_1_8/environment.py b/ix-dev/community/homepage/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/homepage/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/homepage/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/homepage/templates/library/base_v2_1_8/error.py b/ix-dev/community/homepage/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/homepage/templates/library/base_v2_1_8/error.py rename to ix-dev/community/homepage/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/homepage/templates/library/base_v2_1_8/expose.py b/ix-dev/community/homepage/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/homepage/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/homepage/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/homepage/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/homepage/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/homepage/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/homepage/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/homepage/templates/library/base_v2_1_8/functions.py b/ix-dev/community/homepage/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/homepage/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/homepage/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/homepage/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/homepage/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/homepage/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/homepage/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/homepage/templates/library/base_v2_1_8/labels.py b/ix-dev/community/homepage/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/homepage/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/homepage/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/homepage/templates/library/base_v2_1_8/notes.py b/ix-dev/community/homepage/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/homepage/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/homepage/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/homepage/templates/library/base_v2_1_8/portal.py b/ix-dev/community/homepage/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/homepage/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/homepage/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/homepage/templates/library/base_v2_1_8/portals.py b/ix-dev/community/homepage/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/homepage/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/homepage/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/homepage/templates/library/base_v2_1_9/ports.py b/ix-dev/community/homepage/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/homepage/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/homepage/templates/library/base_v2_1_8/render.py b/ix-dev/community/homepage/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/homepage/templates/library/base_v2_1_8/render.py rename to ix-dev/community/homepage/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/homepage/templates/library/base_v2_1_8/resources.py b/ix-dev/community/homepage/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/homepage/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/homepage/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/homepage/templates/library/base_v2_1_8/restart.py b/ix-dev/community/homepage/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/homepage/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/homepage/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/homepage/templates/library/base_v2_1_8/storage.py b/ix-dev/community/homepage/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/homepage/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/homepage/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/homepage/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/homepage/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/homepage/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/homepage/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/homepage/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/homepage/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/homepage/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/homepage/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/homepage/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/homepage/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/homepage/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/homepage/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/homepage/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/homepage/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/homepage/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/homepage/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/homepage/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/homepage/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/homepage/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/homepage/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/homepage/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/homepage/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/homepage/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/homepage/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/homepage/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/homepage/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/homepage/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/homepage/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/homepage/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/homepage/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/homepage/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/homepage/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/homepage/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/homepage/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/homepage/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/homepage/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/homepage/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/homepage/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/homepage/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/homepage/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/homepage/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/homepage/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/homepage/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/homepage/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/homepage/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/homepage/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/homepage/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/homepage/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/homepage/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/homepage/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/homepage/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/homepage/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/homepage/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/homepage/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/homepage/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/homepage/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/homepage/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/homepage/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/homepage/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/homepage/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/homepage/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/homepage/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/homepage/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/homepage/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/homepage/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/homepage/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/homepage/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/homepage/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/homepage/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/homepage/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/homepage/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/homepage/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/homepage/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/homepage/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/homepage/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/homepage/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/homepage/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/homepage/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/homepage/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/homepage/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/homepage/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/homepage/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/homepage/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/homepage/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/homepage/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/homepage/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/homepage/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/homepage/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/homepage/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/homepage/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/homepage/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/homepage/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/homepage/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/homepage/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/homepage/templates/library/base_v2_1_8/validations.py b/ix-dev/community/homepage/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/homepage/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/homepage/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/homepage/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/homepage/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/homepage/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/homepage/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/homepage/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/homepage/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/homepage/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/homepage/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/homepage/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/homepage/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/homepage/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/homepage/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/homepage/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/homepage/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/homepage/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/homepage/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/homepage/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/homepage/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/homepage/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/homepage/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/homer/app.yaml b/ix-dev/community/homer/app.yaml index 1e1d12d926..8886d0e07a 100644 --- a/ix-dev/community/homer/app.yaml +++ b/ix-dev/community/homer/app.yaml @@ -7,8 +7,8 @@ description: Homer is a dead simple static HOMepage for your servER to keep your home: https://github.com/bastienwirtz/homer host_mounts: [] icon: https://media.sys.truenas.net/apps/homer/icons/icon.png -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -30,4 +30,4 @@ tags: - homepage title: Homer train: community -version: 2.1.7 +version: 2.1.8 diff --git a/ix-dev/community/homer/templates/library/base_v2_1_8/container.py b/ix-dev/community/homer/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/homer/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/homer/templates/library/base_v2_1_8/ports.py b/ix-dev/community/homer/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/homer/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/homer/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/homer/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/homer/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/homer/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/homer/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/homer/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/homer/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/homer/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/homer/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/homer/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/homer/templates/library/base_v2_1_8/configs.py b/ix-dev/community/homer/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/homer/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/homer/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/homer/templates/library/base_v2_1_9/container.py b/ix-dev/community/homer/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/homer/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/homer/templates/library/base_v2_1_8/depends.py b/ix-dev/community/homer/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/homer/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/homer/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/homer/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/homer/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/homer/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/homer/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/homer/templates/library/base_v2_1_8/deps.py b/ix-dev/community/homer/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/homer/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/homer/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/homer/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/homer/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/homer/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/homer/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/homer/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/homer/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/homer/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/homer/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/homer/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/homer/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/homer/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/homer/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/homer/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/homer/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/homer/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/homer/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/homer/templates/library/base_v2_1_8/device.py b/ix-dev/community/homer/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/homer/templates/library/base_v2_1_8/device.py rename to ix-dev/community/homer/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/homer/templates/library/base_v2_1_8/devices.py b/ix-dev/community/homer/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/homer/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/homer/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/homer/templates/library/base_v2_1_8/dns.py b/ix-dev/community/homer/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/homer/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/homer/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/homer/templates/library/base_v2_1_8/environment.py b/ix-dev/community/homer/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/homer/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/homer/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/homer/templates/library/base_v2_1_8/error.py b/ix-dev/community/homer/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/homer/templates/library/base_v2_1_8/error.py rename to ix-dev/community/homer/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/homer/templates/library/base_v2_1_8/expose.py b/ix-dev/community/homer/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/homer/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/homer/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/homer/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/homer/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/homer/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/homer/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/homer/templates/library/base_v2_1_8/functions.py b/ix-dev/community/homer/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/homer/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/homer/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/homer/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/homer/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/homer/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/homer/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/homer/templates/library/base_v2_1_8/labels.py b/ix-dev/community/homer/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/homer/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/homer/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/homer/templates/library/base_v2_1_8/notes.py b/ix-dev/community/homer/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/homer/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/homer/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/homer/templates/library/base_v2_1_8/portal.py b/ix-dev/community/homer/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/homer/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/homer/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/homer/templates/library/base_v2_1_8/portals.py b/ix-dev/community/homer/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/homer/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/homer/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/homer/templates/library/base_v2_1_9/ports.py b/ix-dev/community/homer/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/homer/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/homer/templates/library/base_v2_1_8/render.py b/ix-dev/community/homer/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/homer/templates/library/base_v2_1_8/render.py rename to ix-dev/community/homer/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/homer/templates/library/base_v2_1_8/resources.py b/ix-dev/community/homer/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/homer/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/homer/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/homer/templates/library/base_v2_1_8/restart.py b/ix-dev/community/homer/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/homer/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/homer/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/homer/templates/library/base_v2_1_8/storage.py b/ix-dev/community/homer/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/homer/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/homer/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/homer/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/homer/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/homer/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/homer/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/homer/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/homer/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/homer/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/homer/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/homer/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/homer/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/homer/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/homer/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/homer/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/homer/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/homer/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/homer/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/homer/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/homer/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/homer/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/homer/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/homer/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/homer/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/homer/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/homer/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/homer/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/homer/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/homer/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/homer/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/homer/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/homer/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/homer/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/homer/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/homer/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/homer/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/homer/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/homer/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/homer/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/homer/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/homer/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/homer/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/homer/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/homer/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/homer/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/homer/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/homer/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/homer/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/homer/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/homer/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/homer/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/homer/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/homer/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/homer/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/homer/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/homer/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/homer/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/homer/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/homer/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/homer/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/homer/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/homer/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/homer/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/homer/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/homer/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/homer/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/homer/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/homer/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/homer/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/homer/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/homer/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/homer/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/homer/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/homer/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/homer/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/homer/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/homer/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/homer/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/homer/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/homer/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/homer/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/homer/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/homer/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/homer/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/homer/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/homer/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/homer/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/homer/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/homer/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/homer/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/homer/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/homer/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/homer/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/homer/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/homer/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/homer/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/homer/templates/library/base_v2_1_8/validations.py b/ix-dev/community/homer/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/homer/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/homer/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/homer/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/homer/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/homer/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/homer/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/homer/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/homer/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/homer/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/homer/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/homer/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/homer/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/homer/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/homer/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/homer/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/homer/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/homer/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/homer/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/homer/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/homer/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/homer/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/homer/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/iconik-storage-gateway/app.yaml b/ix-dev/community/iconik-storage-gateway/app.yaml index 293f5440be..d1dc4a23cb 100644 --- a/ix-dev/community/iconik-storage-gateway/app.yaml +++ b/ix-dev/community/iconik-storage-gateway/app.yaml @@ -9,8 +9,8 @@ host_mounts: [] icon: https://media.sys.truenas.net/apps/iconik-storage-gateway/icons/icon.svg keywords: - iconik -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -28,4 +28,4 @@ sources: - https://app.iconik.io/help/pages/isg title: Iconik Storage Gateway train: community -version: 1.0.7 +version: 1.0.8 diff --git a/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/container.py b/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/ports.py b/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/configs.py b/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/container.py b/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/depends.py b/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/deps.py b/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/device.py b/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/device.py rename to ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/devices.py b/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/dns.py b/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/environment.py b/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/error.py b/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/error.py rename to ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/expose.py b/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/functions.py b/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/labels.py b/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/notes.py b/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/portal.py b/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/portals.py b/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/ports.py b/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/render.py b/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/render.py rename to ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/resources.py b/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/restart.py b/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/storage.py b/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/validations.py b/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/iconik-storage-gateway/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/immich/app.yaml b/ix-dev/community/immich/app.yaml index 19ca0bc344..50003726ae 100644 --- a/ix-dev/community/immich/app.yaml +++ b/ix-dev/community/immich/app.yaml @@ -16,8 +16,8 @@ icon: https://media.sys.truenas.net/apps/immich/icons/icon.svg keywords: - photos - backup -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -45,4 +45,4 @@ sources: - https://github.com/immich-app/immich title: Immich train: community -version: 1.7.19 +version: 1.7.20 diff --git a/ix-dev/community/immich/templates/library/base_v2_1_8/container.py b/ix-dev/community/immich/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/immich/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/immich/templates/library/base_v2_1_8/ports.py b/ix-dev/community/immich/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/immich/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/immich/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/immich/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/immich/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/immich/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/immich/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/immich/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/immich/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/immich/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/immich/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/immich/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/immich/templates/library/base_v2_1_8/configs.py b/ix-dev/community/immich/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/immich/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/immich/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/immich/templates/library/base_v2_1_9/container.py b/ix-dev/community/immich/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/immich/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/immich/templates/library/base_v2_1_8/depends.py b/ix-dev/community/immich/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/immich/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/immich/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/immich/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/immich/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/immich/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/immich/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/immich/templates/library/base_v2_1_8/deps.py b/ix-dev/community/immich/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/immich/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/immich/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/immich/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/immich/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/immich/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/immich/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/immich/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/immich/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/immich/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/immich/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/immich/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/immich/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/immich/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/immich/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/immich/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/immich/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/immich/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/immich/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/immich/templates/library/base_v2_1_8/device.py b/ix-dev/community/immich/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/immich/templates/library/base_v2_1_8/device.py rename to ix-dev/community/immich/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/immich/templates/library/base_v2_1_8/devices.py b/ix-dev/community/immich/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/immich/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/immich/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/immich/templates/library/base_v2_1_8/dns.py b/ix-dev/community/immich/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/immich/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/immich/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/immich/templates/library/base_v2_1_8/environment.py b/ix-dev/community/immich/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/immich/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/immich/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/immich/templates/library/base_v2_1_8/error.py b/ix-dev/community/immich/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/immich/templates/library/base_v2_1_8/error.py rename to ix-dev/community/immich/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/immich/templates/library/base_v2_1_8/expose.py b/ix-dev/community/immich/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/immich/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/immich/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/immich/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/immich/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/immich/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/immich/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/immich/templates/library/base_v2_1_8/functions.py b/ix-dev/community/immich/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/immich/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/immich/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/immich/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/immich/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/immich/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/immich/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/immich/templates/library/base_v2_1_8/labels.py b/ix-dev/community/immich/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/immich/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/immich/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/immich/templates/library/base_v2_1_8/notes.py b/ix-dev/community/immich/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/immich/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/immich/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/immich/templates/library/base_v2_1_8/portal.py b/ix-dev/community/immich/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/immich/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/immich/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/immich/templates/library/base_v2_1_8/portals.py b/ix-dev/community/immich/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/immich/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/immich/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/immich/templates/library/base_v2_1_9/ports.py b/ix-dev/community/immich/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/immich/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/immich/templates/library/base_v2_1_8/render.py b/ix-dev/community/immich/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/immich/templates/library/base_v2_1_8/render.py rename to ix-dev/community/immich/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/immich/templates/library/base_v2_1_8/resources.py b/ix-dev/community/immich/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/immich/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/immich/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/immich/templates/library/base_v2_1_8/restart.py b/ix-dev/community/immich/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/immich/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/immich/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/immich/templates/library/base_v2_1_8/storage.py b/ix-dev/community/immich/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/immich/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/immich/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/immich/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/immich/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/immich/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/immich/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/immich/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/immich/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/immich/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/immich/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/immich/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/immich/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/immich/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/immich/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/immich/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/immich/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/immich/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/immich/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/immich/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/immich/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/immich/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/immich/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/immich/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/immich/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/immich/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/immich/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/immich/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/immich/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/immich/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/immich/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/immich/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/immich/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/immich/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/immich/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/immich/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/immich/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/immich/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/immich/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/immich/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/immich/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/immich/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/immich/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/immich/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/immich/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/immich/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/immich/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/immich/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/immich/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/immich/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/immich/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/immich/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/immich/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/immich/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/immich/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/immich/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/immich/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/immich/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/immich/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/immich/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/immich/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/immich/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/immich/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/immich/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/immich/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/immich/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/immich/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/immich/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/immich/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/immich/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/immich/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/immich/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/immich/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/immich/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/immich/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/immich/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/immich/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/immich/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/immich/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/immich/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/immich/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/immich/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/immich/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/immich/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/immich/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/immich/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/immich/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/immich/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/immich/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/immich/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/immich/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/immich/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/immich/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/immich/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/immich/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/immich/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/immich/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/immich/templates/library/base_v2_1_8/validations.py b/ix-dev/community/immich/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/immich/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/immich/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/immich/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/immich/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/immich/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/immich/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/immich/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/immich/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/immich/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/immich/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/immich/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/immich/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/immich/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/immich/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/immich/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/immich/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/immich/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/immich/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/immich/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/immich/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/immich/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/immich/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/invidious/app.yaml b/ix-dev/community/invidious/app.yaml index 12fe715773..547fef7276 100644 --- a/ix-dev/community/invidious/app.yaml +++ b/ix-dev/community/invidious/app.yaml @@ -8,8 +8,8 @@ host_mounts: [] icon: https://media.sys.truenas.net/apps/invidious/icons/icon.svg keywords: - youtube -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -38,4 +38,4 @@ sources: - https://quay.io/repository/invidious title: Invidious train: community -version: 1.2.6 +version: 1.2.7 diff --git a/ix-dev/community/invidious/templates/library/base_v2_1_8/container.py b/ix-dev/community/invidious/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/invidious/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/invidious/templates/library/base_v2_1_8/ports.py b/ix-dev/community/invidious/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/invidious/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/invidious/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/invidious/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/invidious/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/invidious/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/invidious/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/invidious/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/invidious/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/invidious/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/invidious/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/invidious/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/invidious/templates/library/base_v2_1_8/configs.py b/ix-dev/community/invidious/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/invidious/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/invidious/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/invidious/templates/library/base_v2_1_9/container.py b/ix-dev/community/invidious/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/invidious/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/invidious/templates/library/base_v2_1_8/depends.py b/ix-dev/community/invidious/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/invidious/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/invidious/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/invidious/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/invidious/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/invidious/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/invidious/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/invidious/templates/library/base_v2_1_8/deps.py b/ix-dev/community/invidious/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/invidious/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/invidious/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/invidious/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/invidious/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/invidious/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/invidious/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/invidious/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/invidious/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/invidious/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/invidious/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/invidious/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/invidious/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/invidious/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/invidious/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/invidious/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/invidious/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/invidious/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/invidious/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/invidious/templates/library/base_v2_1_8/device.py b/ix-dev/community/invidious/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/invidious/templates/library/base_v2_1_8/device.py rename to ix-dev/community/invidious/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/invidious/templates/library/base_v2_1_8/devices.py b/ix-dev/community/invidious/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/invidious/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/invidious/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/invidious/templates/library/base_v2_1_8/dns.py b/ix-dev/community/invidious/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/invidious/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/invidious/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/invidious/templates/library/base_v2_1_8/environment.py b/ix-dev/community/invidious/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/invidious/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/invidious/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/invidious/templates/library/base_v2_1_8/error.py b/ix-dev/community/invidious/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/invidious/templates/library/base_v2_1_8/error.py rename to ix-dev/community/invidious/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/invidious/templates/library/base_v2_1_8/expose.py b/ix-dev/community/invidious/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/invidious/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/invidious/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/invidious/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/invidious/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/invidious/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/invidious/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/invidious/templates/library/base_v2_1_8/functions.py b/ix-dev/community/invidious/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/invidious/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/invidious/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/invidious/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/invidious/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/invidious/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/invidious/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/invidious/templates/library/base_v2_1_8/labels.py b/ix-dev/community/invidious/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/invidious/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/invidious/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/invidious/templates/library/base_v2_1_8/notes.py b/ix-dev/community/invidious/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/invidious/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/invidious/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/invidious/templates/library/base_v2_1_8/portal.py b/ix-dev/community/invidious/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/invidious/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/invidious/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/invidious/templates/library/base_v2_1_8/portals.py b/ix-dev/community/invidious/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/invidious/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/invidious/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/invidious/templates/library/base_v2_1_9/ports.py b/ix-dev/community/invidious/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/invidious/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/invidious/templates/library/base_v2_1_8/render.py b/ix-dev/community/invidious/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/invidious/templates/library/base_v2_1_8/render.py rename to ix-dev/community/invidious/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/invidious/templates/library/base_v2_1_8/resources.py b/ix-dev/community/invidious/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/invidious/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/invidious/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/invidious/templates/library/base_v2_1_8/restart.py b/ix-dev/community/invidious/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/invidious/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/invidious/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/invidious/templates/library/base_v2_1_8/storage.py b/ix-dev/community/invidious/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/invidious/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/invidious/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/invidious/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/invidious/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/invidious/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/invidious/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/invidious/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/invidious/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/invidious/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/invidious/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/invidious/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/invidious/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/invidious/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/invidious/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/invidious/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/invidious/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/invidious/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/invidious/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/invidious/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/invidious/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/invidious/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/invidious/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/invidious/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/invidious/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/invidious/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/invidious/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/invidious/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/invidious/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/invidious/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/invidious/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/invidious/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/invidious/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/invidious/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/invidious/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/invidious/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/invidious/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/invidious/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/invidious/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/invidious/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/invidious/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/invidious/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/invidious/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/invidious/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/invidious/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/invidious/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/invidious/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/invidious/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/invidious/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/invidious/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/invidious/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/invidious/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/invidious/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/invidious/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/invidious/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/invidious/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/invidious/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/invidious/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/invidious/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/invidious/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/invidious/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/invidious/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/invidious/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/invidious/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/invidious/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/invidious/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/invidious/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/invidious/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/invidious/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/invidious/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/invidious/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/invidious/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/invidious/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/invidious/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/invidious/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/invidious/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/invidious/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/invidious/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/invidious/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/invidious/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/invidious/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/invidious/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/invidious/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/invidious/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/invidious/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/invidious/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/invidious/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/invidious/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/invidious/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/invidious/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/invidious/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/invidious/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/invidious/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/invidious/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/invidious/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/invidious/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/invidious/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/invidious/templates/library/base_v2_1_8/validations.py b/ix-dev/community/invidious/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/invidious/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/invidious/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/invidious/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/invidious/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/invidious/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/invidious/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/invidious/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/invidious/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/invidious/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/invidious/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/invidious/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/invidious/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/invidious/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/invidious/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/invidious/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/invidious/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/invidious/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/invidious/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/invidious/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/invidious/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/invidious/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/invidious/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/invoice-ninja/app.yaml b/ix-dev/community/invoice-ninja/app.yaml index 8a2e25cf76..d32d37295a 100644 --- a/ix-dev/community/invoice-ninja/app.yaml +++ b/ix-dev/community/invoice-ninja/app.yaml @@ -25,8 +25,8 @@ host_mounts: [] icon: https://media.sys.truenas.net/apps/invoice-ninja/icons/icon.png keywords: - finance -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -63,4 +63,4 @@ sources: - https://github.com/invoiceninja/dockerfiles title: Invoice Ninja train: community -version: 1.0.5 +version: 1.0.6 diff --git a/ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/container.py b/ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/ports.py b/ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/configs.py b/ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/container.py b/ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/depends.py b/ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/deps.py b/ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/device.py b/ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/device.py rename to ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/devices.py b/ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/dns.py b/ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/environment.py b/ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/error.py b/ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/error.py rename to ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/expose.py b/ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/functions.py b/ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/labels.py b/ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/notes.py b/ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/portal.py b/ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/portals.py b/ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/ports.py b/ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/render.py b/ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/render.py rename to ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/resources.py b/ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/restart.py b/ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/storage.py b/ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/validations.py b/ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/invoice-ninja/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/invoice-ninja/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/ipfs/app.yaml b/ix-dev/community/ipfs/app.yaml index 7ae5f1a73a..5ad0748dc0 100644 --- a/ix-dev/community/ipfs/app.yaml +++ b/ix-dev/community/ipfs/app.yaml @@ -12,8 +12,8 @@ keywords: - ipfs - file-sharing - kubo -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -33,4 +33,4 @@ sources: - https://ipfs.tech/ title: IPFS train: community -version: 1.1.6 +version: 1.1.7 diff --git a/ix-dev/community/ipfs/templates/library/base_v2_1_8/container.py b/ix-dev/community/ipfs/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/ipfs/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/ipfs/templates/library/base_v2_1_8/ports.py b/ix-dev/community/ipfs/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/ipfs/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/ipfs/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/ipfs/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/ipfs/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/ipfs/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/ipfs/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/ipfs/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/ipfs/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/ipfs/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/ipfs/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/ipfs/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/ipfs/templates/library/base_v2_1_8/configs.py b/ix-dev/community/ipfs/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/ipfs/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/ipfs/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/ipfs/templates/library/base_v2_1_9/container.py b/ix-dev/community/ipfs/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/ipfs/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/ipfs/templates/library/base_v2_1_8/depends.py b/ix-dev/community/ipfs/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/ipfs/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/ipfs/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/ipfs/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/ipfs/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/ipfs/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/ipfs/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/ipfs/templates/library/base_v2_1_8/deps.py b/ix-dev/community/ipfs/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/ipfs/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/ipfs/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/ipfs/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/ipfs/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/ipfs/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/ipfs/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/ipfs/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/ipfs/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/ipfs/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/ipfs/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/ipfs/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/ipfs/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/ipfs/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/ipfs/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/ipfs/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/ipfs/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/ipfs/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/ipfs/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/ipfs/templates/library/base_v2_1_8/device.py b/ix-dev/community/ipfs/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/ipfs/templates/library/base_v2_1_8/device.py rename to ix-dev/community/ipfs/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/ipfs/templates/library/base_v2_1_8/devices.py b/ix-dev/community/ipfs/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/ipfs/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/ipfs/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/ipfs/templates/library/base_v2_1_8/dns.py b/ix-dev/community/ipfs/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/ipfs/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/ipfs/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/ipfs/templates/library/base_v2_1_8/environment.py b/ix-dev/community/ipfs/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/ipfs/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/ipfs/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/ipfs/templates/library/base_v2_1_8/error.py b/ix-dev/community/ipfs/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/ipfs/templates/library/base_v2_1_8/error.py rename to ix-dev/community/ipfs/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/ipfs/templates/library/base_v2_1_8/expose.py b/ix-dev/community/ipfs/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/ipfs/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/ipfs/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/ipfs/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/ipfs/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/ipfs/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/ipfs/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/ipfs/templates/library/base_v2_1_8/functions.py b/ix-dev/community/ipfs/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/ipfs/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/ipfs/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/ipfs/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/ipfs/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/ipfs/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/ipfs/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/ipfs/templates/library/base_v2_1_8/labels.py b/ix-dev/community/ipfs/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/ipfs/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/ipfs/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/ipfs/templates/library/base_v2_1_8/notes.py b/ix-dev/community/ipfs/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/ipfs/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/ipfs/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/ipfs/templates/library/base_v2_1_8/portal.py b/ix-dev/community/ipfs/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/ipfs/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/ipfs/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/ipfs/templates/library/base_v2_1_8/portals.py b/ix-dev/community/ipfs/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/ipfs/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/ipfs/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/ipfs/templates/library/base_v2_1_9/ports.py b/ix-dev/community/ipfs/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/ipfs/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/ipfs/templates/library/base_v2_1_8/render.py b/ix-dev/community/ipfs/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/ipfs/templates/library/base_v2_1_8/render.py rename to ix-dev/community/ipfs/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/ipfs/templates/library/base_v2_1_8/resources.py b/ix-dev/community/ipfs/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/ipfs/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/ipfs/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/ipfs/templates/library/base_v2_1_8/restart.py b/ix-dev/community/ipfs/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/ipfs/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/ipfs/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/ipfs/templates/library/base_v2_1_8/storage.py b/ix-dev/community/ipfs/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/ipfs/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/ipfs/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/ipfs/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/ipfs/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/ipfs/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/ipfs/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/ipfs/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/ipfs/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/ipfs/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/ipfs/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/ipfs/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/ipfs/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/ipfs/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/ipfs/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/ipfs/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/ipfs/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/ipfs/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/ipfs/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/ipfs/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/ipfs/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/ipfs/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/ipfs/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/ipfs/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/ipfs/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/ipfs/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/ipfs/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/ipfs/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/ipfs/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/ipfs/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/ipfs/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/ipfs/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/ipfs/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/ipfs/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/ipfs/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/ipfs/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/ipfs/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/ipfs/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/ipfs/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/ipfs/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/ipfs/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/ipfs/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/ipfs/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/ipfs/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/ipfs/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/ipfs/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/ipfs/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/ipfs/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/ipfs/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/ipfs/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/ipfs/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/ipfs/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/ipfs/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/ipfs/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/ipfs/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/ipfs/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/ipfs/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/ipfs/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/ipfs/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/ipfs/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/ipfs/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/ipfs/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/ipfs/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/ipfs/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/ipfs/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/ipfs/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/ipfs/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/ipfs/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/ipfs/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/ipfs/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/ipfs/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/ipfs/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/ipfs/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/ipfs/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/ipfs/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/ipfs/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/ipfs/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/ipfs/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/ipfs/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/ipfs/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/ipfs/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/ipfs/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/ipfs/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/ipfs/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/ipfs/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/ipfs/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/ipfs/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/ipfs/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/ipfs/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/ipfs/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/ipfs/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/ipfs/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/ipfs/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/ipfs/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/ipfs/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/ipfs/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/ipfs/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/ipfs/templates/library/base_v2_1_8/validations.py b/ix-dev/community/ipfs/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/ipfs/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/ipfs/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/ipfs/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/ipfs/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/ipfs/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/ipfs/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/ipfs/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/ipfs/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/ipfs/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/ipfs/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/ipfs/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/ipfs/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/ipfs/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/ipfs/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/ipfs/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/ipfs/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/ipfs/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/ipfs/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/ipfs/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/ipfs/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/ipfs/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/ipfs/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/jellyfin/app.yaml b/ix-dev/community/jellyfin/app.yaml index 7ca46aee1e..47407c1a06 100644 --- a/ix-dev/community/jellyfin/app.yaml +++ b/ix-dev/community/jellyfin/app.yaml @@ -14,8 +14,8 @@ keywords: - tv - media - streaming -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -35,4 +35,4 @@ sources: - https://jellyfin.org/ title: Jellyfin train: community -version: 1.1.12 +version: 1.1.13 diff --git a/ix-dev/community/jellyfin/templates/library/base_v2_1_8/container.py b/ix-dev/community/jellyfin/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/jellyfin/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/jellyfin/templates/library/base_v2_1_8/ports.py b/ix-dev/community/jellyfin/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/jellyfin/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/jellyfin/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/jellyfin/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/jellyfin/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/jellyfin/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/jellyfin/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/jellyfin/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/jellyfin/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/jellyfin/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/jellyfin/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/jellyfin/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/jellyfin/templates/library/base_v2_1_8/configs.py b/ix-dev/community/jellyfin/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/jellyfin/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/jellyfin/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/jellyfin/templates/library/base_v2_1_9/container.py b/ix-dev/community/jellyfin/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/jellyfin/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/jellyfin/templates/library/base_v2_1_8/depends.py b/ix-dev/community/jellyfin/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/jellyfin/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/jellyfin/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/jellyfin/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/jellyfin/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/jellyfin/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/jellyfin/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/jellyfin/templates/library/base_v2_1_8/deps.py b/ix-dev/community/jellyfin/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/jellyfin/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/jellyfin/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/jellyfin/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/jellyfin/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/jellyfin/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/jellyfin/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/jellyfin/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/jellyfin/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/jellyfin/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/jellyfin/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/jellyfin/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/jellyfin/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/jellyfin/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/jellyfin/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/jellyfin/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/jellyfin/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/jellyfin/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/jellyfin/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/jellyfin/templates/library/base_v2_1_8/device.py b/ix-dev/community/jellyfin/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/jellyfin/templates/library/base_v2_1_8/device.py rename to ix-dev/community/jellyfin/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/jellyfin/templates/library/base_v2_1_8/devices.py b/ix-dev/community/jellyfin/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/jellyfin/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/jellyfin/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/jellyfin/templates/library/base_v2_1_8/dns.py b/ix-dev/community/jellyfin/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/jellyfin/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/jellyfin/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/jellyfin/templates/library/base_v2_1_8/environment.py b/ix-dev/community/jellyfin/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/jellyfin/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/jellyfin/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/jellyfin/templates/library/base_v2_1_8/error.py b/ix-dev/community/jellyfin/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/jellyfin/templates/library/base_v2_1_8/error.py rename to ix-dev/community/jellyfin/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/jellyfin/templates/library/base_v2_1_8/expose.py b/ix-dev/community/jellyfin/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/jellyfin/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/jellyfin/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/jellyfin/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/jellyfin/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/jellyfin/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/jellyfin/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/jellyfin/templates/library/base_v2_1_8/functions.py b/ix-dev/community/jellyfin/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/jellyfin/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/jellyfin/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/jellyfin/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/jellyfin/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/jellyfin/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/jellyfin/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/jellyfin/templates/library/base_v2_1_8/labels.py b/ix-dev/community/jellyfin/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/jellyfin/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/jellyfin/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/jellyfin/templates/library/base_v2_1_8/notes.py b/ix-dev/community/jellyfin/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/jellyfin/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/jellyfin/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/jellyfin/templates/library/base_v2_1_8/portal.py b/ix-dev/community/jellyfin/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/jellyfin/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/jellyfin/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/jellyfin/templates/library/base_v2_1_8/portals.py b/ix-dev/community/jellyfin/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/jellyfin/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/jellyfin/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/jellyfin/templates/library/base_v2_1_9/ports.py b/ix-dev/community/jellyfin/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/jellyfin/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/jellyfin/templates/library/base_v2_1_8/render.py b/ix-dev/community/jellyfin/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/jellyfin/templates/library/base_v2_1_8/render.py rename to ix-dev/community/jellyfin/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/jellyfin/templates/library/base_v2_1_8/resources.py b/ix-dev/community/jellyfin/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/jellyfin/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/jellyfin/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/jellyfin/templates/library/base_v2_1_8/restart.py b/ix-dev/community/jellyfin/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/jellyfin/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/jellyfin/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/jellyfin/templates/library/base_v2_1_8/storage.py b/ix-dev/community/jellyfin/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/jellyfin/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/jellyfin/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/jellyfin/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/jellyfin/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/jellyfin/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/jellyfin/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/jellyfin/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/jellyfin/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/jellyfin/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/jellyfin/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/jellyfin/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/jellyfin/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/jellyfin/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/jellyfin/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/jellyfin/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/jellyfin/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/jellyfin/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/jellyfin/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/jellyfin/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/jellyfin/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/jellyfin/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/jellyfin/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/jellyfin/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/jellyfin/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/jellyfin/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/jellyfin/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/jellyfin/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/jellyfin/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/jellyfin/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/jellyfin/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/jellyfin/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/jellyfin/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/jellyfin/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/jellyfin/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/jellyfin/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/jellyfin/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/jellyfin/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/jellyfin/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/jellyfin/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/jellyfin/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/jellyfin/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/jellyfin/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/jellyfin/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/jellyfin/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/jellyfin/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/jellyfin/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/jellyfin/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/jellyfin/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/jellyfin/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/jellyfin/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/jellyfin/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/jellyfin/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/jellyfin/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/jellyfin/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/jellyfin/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/jellyfin/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/jellyfin/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/jellyfin/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/jellyfin/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/jellyfin/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/jellyfin/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/jellyfin/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/jellyfin/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/jellyfin/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/jellyfin/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/jellyfin/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/jellyfin/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/jellyfin/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/jellyfin/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/jellyfin/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/jellyfin/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/jellyfin/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/jellyfin/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/jellyfin/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/jellyfin/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/jellyfin/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/jellyfin/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/jellyfin/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/jellyfin/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/jellyfin/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/jellyfin/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/jellyfin/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/jellyfin/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/jellyfin/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/jellyfin/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/jellyfin/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/jellyfin/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/jellyfin/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/jellyfin/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/jellyfin/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/jellyfin/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/jellyfin/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/jellyfin/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/jellyfin/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/jellyfin/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/jellyfin/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/jellyfin/templates/library/base_v2_1_8/validations.py b/ix-dev/community/jellyfin/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/jellyfin/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/jellyfin/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/jellyfin/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/jellyfin/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/jellyfin/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/jellyfin/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/jellyfin/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/jellyfin/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/jellyfin/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/jellyfin/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/jellyfin/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/jellyfin/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/jellyfin/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/jellyfin/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/jellyfin/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/jellyfin/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/jellyfin/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/jellyfin/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/jellyfin/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/jellyfin/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/jellyfin/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/jellyfin/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/jellyseerr/app.yaml b/ix-dev/community/jellyseerr/app.yaml index bed6a143ff..d368770559 100644 --- a/ix-dev/community/jellyseerr/app.yaml +++ b/ix-dev/community/jellyseerr/app.yaml @@ -9,8 +9,8 @@ host_mounts: [] icon: https://media.sys.truenas.net/apps/jellyseerr/icons/icon.svg keywords: - media -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -29,4 +29,4 @@ sources: - https://hub.docker.com/r/fallenbagel/jellyseerr title: Jellyseerr train: community -version: 1.1.6 +version: 1.1.7 diff --git a/ix-dev/community/jellyseerr/templates/library/base_v2_1_8/container.py b/ix-dev/community/jellyseerr/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/jellyseerr/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/jellyseerr/templates/library/base_v2_1_8/ports.py b/ix-dev/community/jellyseerr/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/jellyseerr/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/jellyseerr/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/jellyseerr/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/jellyseerr/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/jellyseerr/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/jellyseerr/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/jellyseerr/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/jellyseerr/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/jellyseerr/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/jellyseerr/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/jellyseerr/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/jellyseerr/templates/library/base_v2_1_8/configs.py b/ix-dev/community/jellyseerr/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/jellyseerr/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/jellyseerr/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/jellyseerr/templates/library/base_v2_1_9/container.py b/ix-dev/community/jellyseerr/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/jellyseerr/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/jellyseerr/templates/library/base_v2_1_8/depends.py b/ix-dev/community/jellyseerr/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/jellyseerr/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/jellyseerr/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/jellyseerr/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/jellyseerr/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/jellyseerr/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/jellyseerr/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/jellyseerr/templates/library/base_v2_1_8/deps.py b/ix-dev/community/jellyseerr/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/jellyseerr/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/jellyseerr/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/jellyseerr/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/jellyseerr/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/jellyseerr/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/jellyseerr/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/jellyseerr/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/jellyseerr/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/jellyseerr/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/jellyseerr/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/jellyseerr/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/jellyseerr/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/jellyseerr/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/jellyseerr/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/jellyseerr/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/jellyseerr/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/jellyseerr/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/jellyseerr/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/jellyseerr/templates/library/base_v2_1_8/device.py b/ix-dev/community/jellyseerr/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/jellyseerr/templates/library/base_v2_1_8/device.py rename to ix-dev/community/jellyseerr/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/jellyseerr/templates/library/base_v2_1_8/devices.py b/ix-dev/community/jellyseerr/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/jellyseerr/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/jellyseerr/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/jellyseerr/templates/library/base_v2_1_8/dns.py b/ix-dev/community/jellyseerr/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/jellyseerr/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/jellyseerr/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/jellyseerr/templates/library/base_v2_1_8/environment.py b/ix-dev/community/jellyseerr/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/jellyseerr/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/jellyseerr/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/jellyseerr/templates/library/base_v2_1_8/error.py b/ix-dev/community/jellyseerr/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/jellyseerr/templates/library/base_v2_1_8/error.py rename to ix-dev/community/jellyseerr/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/jellyseerr/templates/library/base_v2_1_8/expose.py b/ix-dev/community/jellyseerr/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/jellyseerr/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/jellyseerr/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/jellyseerr/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/jellyseerr/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/jellyseerr/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/jellyseerr/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/jellyseerr/templates/library/base_v2_1_8/functions.py b/ix-dev/community/jellyseerr/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/jellyseerr/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/jellyseerr/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/jellyseerr/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/jellyseerr/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/jellyseerr/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/jellyseerr/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/jellyseerr/templates/library/base_v2_1_8/labels.py b/ix-dev/community/jellyseerr/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/jellyseerr/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/jellyseerr/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/jellyseerr/templates/library/base_v2_1_8/notes.py b/ix-dev/community/jellyseerr/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/jellyseerr/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/jellyseerr/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/jellyseerr/templates/library/base_v2_1_8/portal.py b/ix-dev/community/jellyseerr/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/jellyseerr/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/jellyseerr/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/jellyseerr/templates/library/base_v2_1_8/portals.py b/ix-dev/community/jellyseerr/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/jellyseerr/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/jellyseerr/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/jellyseerr/templates/library/base_v2_1_9/ports.py b/ix-dev/community/jellyseerr/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/jellyseerr/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/jellyseerr/templates/library/base_v2_1_8/render.py b/ix-dev/community/jellyseerr/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/jellyseerr/templates/library/base_v2_1_8/render.py rename to ix-dev/community/jellyseerr/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/jellyseerr/templates/library/base_v2_1_8/resources.py b/ix-dev/community/jellyseerr/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/jellyseerr/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/jellyseerr/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/jellyseerr/templates/library/base_v2_1_8/restart.py b/ix-dev/community/jellyseerr/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/jellyseerr/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/jellyseerr/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/jellyseerr/templates/library/base_v2_1_8/storage.py b/ix-dev/community/jellyseerr/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/jellyseerr/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/jellyseerr/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/jellyseerr/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/jellyseerr/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/jellyseerr/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/jellyseerr/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/jellyseerr/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/jellyseerr/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/jellyseerr/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/jellyseerr/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/jellyseerr/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/jellyseerr/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/jellyseerr/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/jellyseerr/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/jellyseerr/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/jellyseerr/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/jellyseerr/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/jellyseerr/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/jellyseerr/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/jellyseerr/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/jellyseerr/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/jellyseerr/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/jellyseerr/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/jellyseerr/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/jellyseerr/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/jellyseerr/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/jellyseerr/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/jellyseerr/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/jellyseerr/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/jellyseerr/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/jellyseerr/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/jellyseerr/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/jellyseerr/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/jellyseerr/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/jellyseerr/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/jellyseerr/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/jellyseerr/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/jellyseerr/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/jellyseerr/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/jellyseerr/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/jellyseerr/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/jellyseerr/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/jellyseerr/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/jellyseerr/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/jellyseerr/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/jellyseerr/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/jellyseerr/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/jellyseerr/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/jellyseerr/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/jellyseerr/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/jellyseerr/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/jellyseerr/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/jellyseerr/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/jellyseerr/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/jellyseerr/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/jellyseerr/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/jellyseerr/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/jellyseerr/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/jellyseerr/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/jellyseerr/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/jellyseerr/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/jellyseerr/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/jellyseerr/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/jellyseerr/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/jellyseerr/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/jellyseerr/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/jellyseerr/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/jellyseerr/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/jellyseerr/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/jellyseerr/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/jellyseerr/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/jellyseerr/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/jellyseerr/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/jellyseerr/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/jellyseerr/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/jellyseerr/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/jellyseerr/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/jellyseerr/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/jellyseerr/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/jellyseerr/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/jellyseerr/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/jellyseerr/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/jellyseerr/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/jellyseerr/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/jellyseerr/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/jellyseerr/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/jellyseerr/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/jellyseerr/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/jellyseerr/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/jellyseerr/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/jellyseerr/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/jellyseerr/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/jellyseerr/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/jellyseerr/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/jellyseerr/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/jellyseerr/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/jellyseerr/templates/library/base_v2_1_8/validations.py b/ix-dev/community/jellyseerr/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/jellyseerr/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/jellyseerr/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/jellyseerr/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/jellyseerr/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/jellyseerr/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/jellyseerr/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/jellyseerr/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/jellyseerr/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/jellyseerr/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/jellyseerr/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/jellyseerr/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/jellyseerr/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/jellyseerr/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/jellyseerr/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/jellyseerr/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/jellyseerr/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/jellyseerr/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/jellyseerr/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/jellyseerr/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/jellyseerr/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/jellyseerr/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/jellyseerr/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/jelu/app.yaml b/ix-dev/community/jelu/app.yaml index 7fe3c486e9..2ebd3c7b20 100644 --- a/ix-dev/community/jelu/app.yaml +++ b/ix-dev/community/jelu/app.yaml @@ -9,8 +9,8 @@ icon: https://media.sys.truenas.net/apps/jelu/icons/icon.png keywords: - media - book -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -30,4 +30,4 @@ sources: - https://hub.docker.com/repository/docker/wabayang/jelu title: Jelu train: community -version: 1.0.2 +version: 1.0.3 diff --git a/ix-dev/community/jelu/templates/library/base_v2_1_8/container.py b/ix-dev/community/jelu/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/jelu/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/jelu/templates/library/base_v2_1_8/ports.py b/ix-dev/community/jelu/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/jelu/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/jelu/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/jelu/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/jelu/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/jelu/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/jelu/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/jelu/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/jelu/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/jelu/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/jelu/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/jelu/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/jelu/templates/library/base_v2_1_8/configs.py b/ix-dev/community/jelu/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/jelu/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/jelu/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/jelu/templates/library/base_v2_1_9/container.py b/ix-dev/community/jelu/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/jelu/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/jelu/templates/library/base_v2_1_8/depends.py b/ix-dev/community/jelu/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/jelu/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/jelu/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/jelu/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/jelu/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/jelu/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/jelu/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/jelu/templates/library/base_v2_1_8/deps.py b/ix-dev/community/jelu/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/jelu/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/jelu/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/jelu/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/jelu/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/jelu/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/jelu/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/jelu/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/jelu/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/jelu/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/jelu/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/jelu/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/jelu/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/jelu/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/jelu/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/jelu/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/jelu/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/jelu/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/jelu/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/jelu/templates/library/base_v2_1_8/device.py b/ix-dev/community/jelu/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/jelu/templates/library/base_v2_1_8/device.py rename to ix-dev/community/jelu/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/jelu/templates/library/base_v2_1_8/devices.py b/ix-dev/community/jelu/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/jelu/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/jelu/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/jelu/templates/library/base_v2_1_8/dns.py b/ix-dev/community/jelu/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/jelu/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/jelu/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/jelu/templates/library/base_v2_1_8/environment.py b/ix-dev/community/jelu/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/jelu/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/jelu/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/jelu/templates/library/base_v2_1_8/error.py b/ix-dev/community/jelu/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/jelu/templates/library/base_v2_1_8/error.py rename to ix-dev/community/jelu/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/jelu/templates/library/base_v2_1_8/expose.py b/ix-dev/community/jelu/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/jelu/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/jelu/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/jelu/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/jelu/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/jelu/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/jelu/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/jelu/templates/library/base_v2_1_8/functions.py b/ix-dev/community/jelu/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/jelu/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/jelu/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/jelu/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/jelu/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/jelu/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/jelu/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/jelu/templates/library/base_v2_1_8/labels.py b/ix-dev/community/jelu/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/jelu/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/jelu/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/jelu/templates/library/base_v2_1_8/notes.py b/ix-dev/community/jelu/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/jelu/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/jelu/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/jelu/templates/library/base_v2_1_8/portal.py b/ix-dev/community/jelu/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/jelu/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/jelu/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/jelu/templates/library/base_v2_1_8/portals.py b/ix-dev/community/jelu/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/jelu/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/jelu/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/jelu/templates/library/base_v2_1_9/ports.py b/ix-dev/community/jelu/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/jelu/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/jelu/templates/library/base_v2_1_8/render.py b/ix-dev/community/jelu/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/jelu/templates/library/base_v2_1_8/render.py rename to ix-dev/community/jelu/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/jelu/templates/library/base_v2_1_8/resources.py b/ix-dev/community/jelu/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/jelu/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/jelu/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/jelu/templates/library/base_v2_1_8/restart.py b/ix-dev/community/jelu/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/jelu/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/jelu/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/jelu/templates/library/base_v2_1_8/storage.py b/ix-dev/community/jelu/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/jelu/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/jelu/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/jelu/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/jelu/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/jelu/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/jelu/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/jelu/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/jelu/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/jelu/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/jelu/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/jelu/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/jelu/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/jelu/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/jelu/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/jelu/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/jelu/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/jelu/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/jelu/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/jelu/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/jelu/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/jelu/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/jelu/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/jelu/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/jelu/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/jelu/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/jelu/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/jelu/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/jelu/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/jelu/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/jelu/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/jelu/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/jelu/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/jelu/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/jelu/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/jelu/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/jelu/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/jelu/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/jelu/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/jelu/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/jelu/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/jelu/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/jelu/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/jelu/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/jelu/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/jelu/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/jelu/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/jelu/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/jelu/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/jelu/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/jelu/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/jelu/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/jelu/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/jelu/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/jelu/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/jelu/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/jelu/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/jelu/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/jelu/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/jelu/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/jelu/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/jelu/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/jelu/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/jelu/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/jelu/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/jelu/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/jelu/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/jelu/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/jelu/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/jelu/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/jelu/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/jelu/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/jelu/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/jelu/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/jelu/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/jelu/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/jelu/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/jelu/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/jelu/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/jelu/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/jelu/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/jelu/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/jelu/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/jelu/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/jelu/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/jelu/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/jelu/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/jelu/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/jelu/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/jelu/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/jelu/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/jelu/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/jelu/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/jelu/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/jelu/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/jelu/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/jelu/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/jelu/templates/library/base_v2_1_8/validations.py b/ix-dev/community/jelu/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/jelu/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/jelu/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/jelu/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/jelu/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/jelu/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/jelu/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/jelu/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/jelu/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/jelu/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/jelu/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/jelu/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/jelu/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/jelu/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/jelu/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/jelu/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/jelu/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/jelu/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/jelu/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/jelu/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/jelu/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/jelu/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/jelu/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/jenkins/app.yaml b/ix-dev/community/jenkins/app.yaml index 948d9b7cce..bb2f670033 100644 --- a/ix-dev/community/jenkins/app.yaml +++ b/ix-dev/community/jenkins/app.yaml @@ -9,8 +9,8 @@ icon: https://media.sys.truenas.net/apps/jenkins/icons/icon.svg keywords: - automation - ci/cd -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -31,4 +31,4 @@ sources: - https://www.jenkins.io/ title: Jenkins train: community -version: 1.1.7 +version: 1.1.8 diff --git a/ix-dev/community/jenkins/templates/library/base_v2_1_8/container.py b/ix-dev/community/jenkins/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/jenkins/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/jenkins/templates/library/base_v2_1_8/ports.py b/ix-dev/community/jenkins/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/jenkins/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/jenkins/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/jenkins/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/jenkins/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/jenkins/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/jenkins/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/jenkins/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/jenkins/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/jenkins/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/jenkins/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/jenkins/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/jenkins/templates/library/base_v2_1_8/configs.py b/ix-dev/community/jenkins/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/jenkins/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/jenkins/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/jenkins/templates/library/base_v2_1_9/container.py b/ix-dev/community/jenkins/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/jenkins/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/jenkins/templates/library/base_v2_1_8/depends.py b/ix-dev/community/jenkins/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/jenkins/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/jenkins/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/jenkins/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/jenkins/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/jenkins/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/jenkins/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/jenkins/templates/library/base_v2_1_8/deps.py b/ix-dev/community/jenkins/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/jenkins/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/jenkins/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/jenkins/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/jenkins/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/jenkins/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/jenkins/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/jenkins/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/jenkins/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/jenkins/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/jenkins/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/jenkins/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/jenkins/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/jenkins/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/jenkins/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/jenkins/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/jenkins/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/jenkins/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/jenkins/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/jenkins/templates/library/base_v2_1_8/device.py b/ix-dev/community/jenkins/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/jenkins/templates/library/base_v2_1_8/device.py rename to ix-dev/community/jenkins/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/jenkins/templates/library/base_v2_1_8/devices.py b/ix-dev/community/jenkins/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/jenkins/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/jenkins/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/jenkins/templates/library/base_v2_1_8/dns.py b/ix-dev/community/jenkins/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/jenkins/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/jenkins/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/jenkins/templates/library/base_v2_1_8/environment.py b/ix-dev/community/jenkins/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/jenkins/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/jenkins/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/jenkins/templates/library/base_v2_1_8/error.py b/ix-dev/community/jenkins/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/jenkins/templates/library/base_v2_1_8/error.py rename to ix-dev/community/jenkins/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/jenkins/templates/library/base_v2_1_8/expose.py b/ix-dev/community/jenkins/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/jenkins/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/jenkins/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/jenkins/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/jenkins/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/jenkins/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/jenkins/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/jenkins/templates/library/base_v2_1_8/functions.py b/ix-dev/community/jenkins/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/jenkins/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/jenkins/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/jenkins/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/jenkins/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/jenkins/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/jenkins/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/jenkins/templates/library/base_v2_1_8/labels.py b/ix-dev/community/jenkins/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/jenkins/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/jenkins/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/jenkins/templates/library/base_v2_1_8/notes.py b/ix-dev/community/jenkins/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/jenkins/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/jenkins/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/jenkins/templates/library/base_v2_1_8/portal.py b/ix-dev/community/jenkins/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/jenkins/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/jenkins/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/jenkins/templates/library/base_v2_1_8/portals.py b/ix-dev/community/jenkins/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/jenkins/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/jenkins/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/jenkins/templates/library/base_v2_1_9/ports.py b/ix-dev/community/jenkins/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/jenkins/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/jenkins/templates/library/base_v2_1_8/render.py b/ix-dev/community/jenkins/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/jenkins/templates/library/base_v2_1_8/render.py rename to ix-dev/community/jenkins/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/jenkins/templates/library/base_v2_1_8/resources.py b/ix-dev/community/jenkins/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/jenkins/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/jenkins/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/jenkins/templates/library/base_v2_1_8/restart.py b/ix-dev/community/jenkins/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/jenkins/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/jenkins/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/jenkins/templates/library/base_v2_1_8/storage.py b/ix-dev/community/jenkins/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/jenkins/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/jenkins/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/jenkins/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/jenkins/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/jenkins/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/jenkins/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/jenkins/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/jenkins/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/jenkins/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/jenkins/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/jenkins/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/jenkins/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/jenkins/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/jenkins/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/jenkins/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/jenkins/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/jenkins/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/jenkins/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/jenkins/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/jenkins/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/jenkins/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/jenkins/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/jenkins/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/jenkins/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/jenkins/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/jenkins/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/jenkins/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/jenkins/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/jenkins/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/jenkins/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/jenkins/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/jenkins/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/jenkins/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/jenkins/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/jenkins/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/jenkins/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/jenkins/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/jenkins/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/jenkins/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/jenkins/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/jenkins/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/jenkins/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/jenkins/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/jenkins/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/jenkins/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/jenkins/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/jenkins/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/jenkins/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/jenkins/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/jenkins/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/jenkins/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/jenkins/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/jenkins/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/jenkins/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/jenkins/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/jenkins/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/jenkins/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/jenkins/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/jenkins/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/jenkins/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/jenkins/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/jenkins/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/jenkins/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/jenkins/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/jenkins/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/jenkins/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/jenkins/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/jenkins/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/jenkins/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/jenkins/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/jenkins/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/jenkins/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/jenkins/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/jenkins/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/jenkins/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/jenkins/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/jenkins/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/jenkins/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/jenkins/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/jenkins/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/jenkins/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/jenkins/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/jenkins/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/jenkins/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/jenkins/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/jenkins/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/jenkins/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/jenkins/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/jenkins/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/jenkins/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/jenkins/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/jenkins/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/jenkins/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/jenkins/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/jenkins/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/jenkins/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/jenkins/templates/library/base_v2_1_8/validations.py b/ix-dev/community/jenkins/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/jenkins/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/jenkins/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/jenkins/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/jenkins/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/jenkins/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/jenkins/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/jenkins/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/jenkins/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/jenkins/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/jenkins/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/jenkins/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/jenkins/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/jenkins/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/jenkins/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/jenkins/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/jenkins/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/jenkins/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/jenkins/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/jenkins/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/jenkins/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/jenkins/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/jenkins/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/joplin/app.yaml b/ix-dev/community/joplin/app.yaml index 8ae3bc465d..7a69b149ea 100644 --- a/ix-dev/community/joplin/app.yaml +++ b/ix-dev/community/joplin/app.yaml @@ -9,8 +9,8 @@ host_mounts: [] icon: https://media.sys.truenas.net/apps/joplin/icons/icon.png keywords: - notes -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -36,4 +36,4 @@ sources: - https://hub.docker.com/r/joplin/server/ title: Joplin train: community -version: 1.3.4 +version: 1.3.5 diff --git a/ix-dev/community/joplin/templates/library/base_v2_1_8/container.py b/ix-dev/community/joplin/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/joplin/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/joplin/templates/library/base_v2_1_8/ports.py b/ix-dev/community/joplin/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/joplin/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/joplin/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/joplin/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/joplin/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/joplin/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/joplin/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/joplin/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/joplin/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/joplin/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/joplin/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/joplin/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/joplin/templates/library/base_v2_1_8/configs.py b/ix-dev/community/joplin/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/joplin/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/joplin/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/joplin/templates/library/base_v2_1_9/container.py b/ix-dev/community/joplin/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/joplin/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/joplin/templates/library/base_v2_1_8/depends.py b/ix-dev/community/joplin/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/joplin/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/joplin/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/joplin/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/joplin/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/joplin/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/joplin/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/joplin/templates/library/base_v2_1_8/deps.py b/ix-dev/community/joplin/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/joplin/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/joplin/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/joplin/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/joplin/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/joplin/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/joplin/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/joplin/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/joplin/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/joplin/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/joplin/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/joplin/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/joplin/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/joplin/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/joplin/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/joplin/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/joplin/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/joplin/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/joplin/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/joplin/templates/library/base_v2_1_8/device.py b/ix-dev/community/joplin/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/joplin/templates/library/base_v2_1_8/device.py rename to ix-dev/community/joplin/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/joplin/templates/library/base_v2_1_8/devices.py b/ix-dev/community/joplin/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/joplin/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/joplin/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/joplin/templates/library/base_v2_1_8/dns.py b/ix-dev/community/joplin/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/joplin/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/joplin/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/joplin/templates/library/base_v2_1_8/environment.py b/ix-dev/community/joplin/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/joplin/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/joplin/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/joplin/templates/library/base_v2_1_8/error.py b/ix-dev/community/joplin/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/joplin/templates/library/base_v2_1_8/error.py rename to ix-dev/community/joplin/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/joplin/templates/library/base_v2_1_8/expose.py b/ix-dev/community/joplin/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/joplin/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/joplin/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/joplin/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/joplin/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/joplin/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/joplin/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/joplin/templates/library/base_v2_1_8/functions.py b/ix-dev/community/joplin/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/joplin/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/joplin/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/joplin/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/joplin/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/joplin/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/joplin/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/joplin/templates/library/base_v2_1_8/labels.py b/ix-dev/community/joplin/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/joplin/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/joplin/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/joplin/templates/library/base_v2_1_8/notes.py b/ix-dev/community/joplin/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/joplin/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/joplin/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/joplin/templates/library/base_v2_1_8/portal.py b/ix-dev/community/joplin/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/joplin/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/joplin/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/joplin/templates/library/base_v2_1_8/portals.py b/ix-dev/community/joplin/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/joplin/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/joplin/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/joplin/templates/library/base_v2_1_9/ports.py b/ix-dev/community/joplin/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/joplin/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/joplin/templates/library/base_v2_1_8/render.py b/ix-dev/community/joplin/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/joplin/templates/library/base_v2_1_8/render.py rename to ix-dev/community/joplin/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/joplin/templates/library/base_v2_1_8/resources.py b/ix-dev/community/joplin/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/joplin/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/joplin/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/joplin/templates/library/base_v2_1_8/restart.py b/ix-dev/community/joplin/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/joplin/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/joplin/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/joplin/templates/library/base_v2_1_8/storage.py b/ix-dev/community/joplin/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/joplin/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/joplin/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/joplin/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/joplin/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/joplin/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/joplin/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/joplin/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/joplin/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/joplin/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/joplin/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/joplin/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/joplin/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/joplin/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/joplin/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/joplin/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/joplin/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/joplin/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/joplin/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/joplin/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/joplin/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/joplin/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/joplin/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/joplin/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/joplin/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/joplin/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/joplin/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/joplin/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/joplin/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/joplin/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/joplin/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/joplin/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/joplin/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/joplin/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/joplin/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/joplin/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/joplin/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/joplin/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/joplin/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/joplin/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/joplin/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/joplin/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/joplin/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/joplin/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/joplin/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/joplin/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/joplin/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/joplin/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/joplin/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/joplin/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/joplin/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/joplin/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/joplin/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/joplin/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/joplin/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/joplin/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/joplin/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/joplin/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/joplin/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/joplin/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/joplin/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/joplin/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/joplin/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/joplin/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/joplin/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/joplin/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/joplin/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/joplin/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/joplin/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/joplin/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/joplin/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/joplin/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/joplin/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/joplin/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/joplin/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/joplin/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/joplin/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/joplin/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/joplin/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/joplin/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/joplin/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/joplin/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/joplin/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/joplin/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/joplin/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/joplin/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/joplin/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/joplin/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/joplin/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/joplin/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/joplin/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/joplin/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/joplin/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/joplin/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/joplin/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/joplin/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/joplin/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/joplin/templates/library/base_v2_1_8/validations.py b/ix-dev/community/joplin/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/joplin/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/joplin/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/joplin/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/joplin/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/joplin/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/joplin/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/joplin/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/joplin/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/joplin/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/joplin/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/joplin/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/joplin/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/joplin/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/joplin/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/joplin/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/joplin/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/joplin/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/joplin/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/joplin/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/joplin/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/joplin/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/joplin/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/kapowarr/app.yaml b/ix-dev/community/kapowarr/app.yaml index 470c09cdb9..d7584dcefa 100644 --- a/ix-dev/community/kapowarr/app.yaml +++ b/ix-dev/community/kapowarr/app.yaml @@ -10,8 +10,8 @@ icon: https://media.sys.truenas.net/apps/kapowarr/icons/icon.svg keywords: - comic - media -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -31,4 +31,4 @@ sources: - https://github.com/Casvt/Kapowarr title: Kapowarr train: community -version: 1.1.5 +version: 1.1.6 diff --git a/ix-dev/community/kapowarr/templates/library/base_v2_1_8/container.py b/ix-dev/community/kapowarr/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/kapowarr/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/kapowarr/templates/library/base_v2_1_8/ports.py b/ix-dev/community/kapowarr/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/kapowarr/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/kapowarr/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/kapowarr/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/kapowarr/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/kapowarr/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/kapowarr/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/kapowarr/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/kapowarr/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/kapowarr/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/kapowarr/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/kapowarr/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/kapowarr/templates/library/base_v2_1_8/configs.py b/ix-dev/community/kapowarr/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/kapowarr/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/kapowarr/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/kapowarr/templates/library/base_v2_1_9/container.py b/ix-dev/community/kapowarr/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/kapowarr/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/kapowarr/templates/library/base_v2_1_8/depends.py b/ix-dev/community/kapowarr/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/kapowarr/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/kapowarr/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/kapowarr/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/kapowarr/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/kapowarr/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/kapowarr/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/kapowarr/templates/library/base_v2_1_8/deps.py b/ix-dev/community/kapowarr/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/kapowarr/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/kapowarr/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/kapowarr/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/kapowarr/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/kapowarr/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/kapowarr/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/kapowarr/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/kapowarr/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/kapowarr/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/kapowarr/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/kapowarr/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/kapowarr/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/kapowarr/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/kapowarr/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/kapowarr/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/kapowarr/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/kapowarr/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/kapowarr/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/kapowarr/templates/library/base_v2_1_8/device.py b/ix-dev/community/kapowarr/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/kapowarr/templates/library/base_v2_1_8/device.py rename to ix-dev/community/kapowarr/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/kapowarr/templates/library/base_v2_1_8/devices.py b/ix-dev/community/kapowarr/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/kapowarr/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/kapowarr/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/kapowarr/templates/library/base_v2_1_8/dns.py b/ix-dev/community/kapowarr/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/kapowarr/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/kapowarr/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/kapowarr/templates/library/base_v2_1_8/environment.py b/ix-dev/community/kapowarr/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/kapowarr/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/kapowarr/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/kapowarr/templates/library/base_v2_1_8/error.py b/ix-dev/community/kapowarr/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/kapowarr/templates/library/base_v2_1_8/error.py rename to ix-dev/community/kapowarr/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/kapowarr/templates/library/base_v2_1_8/expose.py b/ix-dev/community/kapowarr/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/kapowarr/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/kapowarr/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/kapowarr/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/kapowarr/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/kapowarr/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/kapowarr/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/kapowarr/templates/library/base_v2_1_8/functions.py b/ix-dev/community/kapowarr/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/kapowarr/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/kapowarr/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/kapowarr/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/kapowarr/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/kapowarr/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/kapowarr/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/kapowarr/templates/library/base_v2_1_8/labels.py b/ix-dev/community/kapowarr/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/kapowarr/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/kapowarr/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/kapowarr/templates/library/base_v2_1_8/notes.py b/ix-dev/community/kapowarr/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/kapowarr/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/kapowarr/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/kapowarr/templates/library/base_v2_1_8/portal.py b/ix-dev/community/kapowarr/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/kapowarr/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/kapowarr/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/kapowarr/templates/library/base_v2_1_8/portals.py b/ix-dev/community/kapowarr/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/kapowarr/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/kapowarr/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/kapowarr/templates/library/base_v2_1_9/ports.py b/ix-dev/community/kapowarr/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/kapowarr/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/kapowarr/templates/library/base_v2_1_8/render.py b/ix-dev/community/kapowarr/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/kapowarr/templates/library/base_v2_1_8/render.py rename to ix-dev/community/kapowarr/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/kapowarr/templates/library/base_v2_1_8/resources.py b/ix-dev/community/kapowarr/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/kapowarr/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/kapowarr/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/kapowarr/templates/library/base_v2_1_8/restart.py b/ix-dev/community/kapowarr/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/kapowarr/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/kapowarr/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/kapowarr/templates/library/base_v2_1_8/storage.py b/ix-dev/community/kapowarr/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/kapowarr/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/kapowarr/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/kapowarr/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/kapowarr/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/kapowarr/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/kapowarr/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/kapowarr/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/kapowarr/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/kapowarr/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/kapowarr/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/kapowarr/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/kapowarr/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/kapowarr/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/kapowarr/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/kapowarr/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/kapowarr/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/kapowarr/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/kapowarr/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/kapowarr/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/kapowarr/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/kapowarr/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/kapowarr/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/kapowarr/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/kapowarr/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/kapowarr/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/kapowarr/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/kapowarr/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/kapowarr/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/kapowarr/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/kapowarr/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/kapowarr/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/kapowarr/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/kapowarr/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/kapowarr/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/kapowarr/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/kapowarr/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/kapowarr/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/kapowarr/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/kapowarr/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/kapowarr/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/kapowarr/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/kapowarr/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/kapowarr/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/kapowarr/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/kapowarr/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/kapowarr/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/kapowarr/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/kapowarr/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/kapowarr/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/kapowarr/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/kapowarr/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/kapowarr/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/kapowarr/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/kapowarr/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/kapowarr/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/kapowarr/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/kapowarr/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/kapowarr/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/kapowarr/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/kapowarr/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/kapowarr/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/kapowarr/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/kapowarr/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/kapowarr/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/kapowarr/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/kapowarr/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/kapowarr/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/kapowarr/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/kapowarr/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/kapowarr/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/kapowarr/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/kapowarr/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/kapowarr/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/kapowarr/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/kapowarr/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/kapowarr/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/kapowarr/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/kapowarr/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/kapowarr/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/kapowarr/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/kapowarr/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/kapowarr/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/kapowarr/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/kapowarr/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/kapowarr/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/kapowarr/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/kapowarr/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/kapowarr/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/kapowarr/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/kapowarr/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/kapowarr/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/kapowarr/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/kapowarr/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/kapowarr/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/kapowarr/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/kapowarr/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/kapowarr/templates/library/base_v2_1_8/validations.py b/ix-dev/community/kapowarr/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/kapowarr/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/kapowarr/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/kapowarr/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/kapowarr/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/kapowarr/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/kapowarr/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/kapowarr/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/kapowarr/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/kapowarr/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/kapowarr/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/kapowarr/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/kapowarr/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/kapowarr/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/kapowarr/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/kapowarr/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/kapowarr/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/kapowarr/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/kapowarr/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/kapowarr/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/kapowarr/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/kapowarr/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/kapowarr/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/kavita/app.yaml b/ix-dev/community/kavita/app.yaml index 52d6ea313a..462e3e28c0 100644 --- a/ix-dev/community/kavita/app.yaml +++ b/ix-dev/community/kavita/app.yaml @@ -20,8 +20,8 @@ icon: https://media.sys.truenas.net/apps/kavita/icons/icon.png keywords: - ebook - manga -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -44,4 +44,4 @@ sources: - https://www.kavitareader.com title: Kavita train: community -version: 1.1.5 +version: 1.1.6 diff --git a/ix-dev/community/kavita/templates/library/base_v2_1_8/container.py b/ix-dev/community/kavita/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/kavita/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/kavita/templates/library/base_v2_1_8/ports.py b/ix-dev/community/kavita/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/kavita/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/kavita/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/kavita/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/kavita/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/kavita/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/kavita/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/kavita/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/kavita/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/kavita/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/kavita/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/kavita/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/kavita/templates/library/base_v2_1_8/configs.py b/ix-dev/community/kavita/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/kavita/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/kavita/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/kavita/templates/library/base_v2_1_9/container.py b/ix-dev/community/kavita/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/kavita/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/kavita/templates/library/base_v2_1_8/depends.py b/ix-dev/community/kavita/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/kavita/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/kavita/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/kavita/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/kavita/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/kavita/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/kavita/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/kavita/templates/library/base_v2_1_8/deps.py b/ix-dev/community/kavita/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/kavita/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/kavita/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/kavita/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/kavita/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/kavita/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/kavita/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/kavita/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/kavita/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/kavita/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/kavita/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/kavita/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/kavita/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/kavita/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/kavita/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/kavita/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/kavita/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/kavita/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/kavita/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/kavita/templates/library/base_v2_1_8/device.py b/ix-dev/community/kavita/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/kavita/templates/library/base_v2_1_8/device.py rename to ix-dev/community/kavita/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/kavita/templates/library/base_v2_1_8/devices.py b/ix-dev/community/kavita/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/kavita/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/kavita/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/kavita/templates/library/base_v2_1_8/dns.py b/ix-dev/community/kavita/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/kavita/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/kavita/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/kavita/templates/library/base_v2_1_8/environment.py b/ix-dev/community/kavita/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/kavita/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/kavita/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/kavita/templates/library/base_v2_1_8/error.py b/ix-dev/community/kavita/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/kavita/templates/library/base_v2_1_8/error.py rename to ix-dev/community/kavita/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/kavita/templates/library/base_v2_1_8/expose.py b/ix-dev/community/kavita/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/kavita/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/kavita/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/kavita/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/kavita/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/kavita/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/kavita/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/kavita/templates/library/base_v2_1_8/functions.py b/ix-dev/community/kavita/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/kavita/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/kavita/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/kavita/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/kavita/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/kavita/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/kavita/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/kavita/templates/library/base_v2_1_8/labels.py b/ix-dev/community/kavita/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/kavita/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/kavita/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/kavita/templates/library/base_v2_1_8/notes.py b/ix-dev/community/kavita/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/kavita/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/kavita/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/kavita/templates/library/base_v2_1_8/portal.py b/ix-dev/community/kavita/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/kavita/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/kavita/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/kavita/templates/library/base_v2_1_8/portals.py b/ix-dev/community/kavita/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/kavita/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/kavita/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/kavita/templates/library/base_v2_1_9/ports.py b/ix-dev/community/kavita/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/kavita/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/kavita/templates/library/base_v2_1_8/render.py b/ix-dev/community/kavita/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/kavita/templates/library/base_v2_1_8/render.py rename to ix-dev/community/kavita/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/kavita/templates/library/base_v2_1_8/resources.py b/ix-dev/community/kavita/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/kavita/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/kavita/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/kavita/templates/library/base_v2_1_8/restart.py b/ix-dev/community/kavita/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/kavita/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/kavita/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/kavita/templates/library/base_v2_1_8/storage.py b/ix-dev/community/kavita/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/kavita/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/kavita/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/kavita/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/kavita/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/kavita/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/kavita/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/kavita/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/kavita/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/kavita/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/kavita/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/kavita/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/kavita/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/kavita/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/kavita/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/kavita/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/kavita/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/kavita/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/kavita/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/kavita/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/kavita/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/kavita/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/kavita/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/kavita/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/kavita/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/kavita/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/kavita/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/kavita/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/kavita/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/kavita/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/kavita/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/kavita/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/kavita/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/kavita/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/kavita/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/kavita/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/kavita/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/kavita/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/kavita/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/kavita/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/kavita/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/kavita/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/kavita/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/kavita/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/kavita/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/kavita/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/kavita/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/kavita/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/kavita/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/kavita/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/kavita/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/kavita/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/kavita/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/kavita/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/kavita/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/kavita/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/kavita/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/kavita/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/kavita/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/kavita/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/kavita/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/kavita/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/kavita/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/kavita/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/kavita/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/kavita/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/kavita/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/kavita/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/kavita/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/kavita/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/kavita/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/kavita/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/kavita/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/kavita/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/kavita/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/kavita/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/kavita/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/kavita/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/kavita/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/kavita/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/kavita/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/kavita/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/kavita/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/kavita/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/kavita/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/kavita/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/kavita/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/kavita/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/kavita/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/kavita/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/kavita/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/kavita/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/kavita/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/kavita/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/kavita/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/kavita/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/kavita/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/kavita/templates/library/base_v2_1_8/validations.py b/ix-dev/community/kavita/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/kavita/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/kavita/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/kavita/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/kavita/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/kavita/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/kavita/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/kavita/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/kavita/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/kavita/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/kavita/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/kavita/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/kavita/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/kavita/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/kavita/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/kavita/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/kavita/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/kavita/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/kavita/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/kavita/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/kavita/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/kavita/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/kavita/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/komga/app.yaml b/ix-dev/community/komga/app.yaml index eaf2b11301..3048d1d39d 100644 --- a/ix-dev/community/komga/app.yaml +++ b/ix-dev/community/komga/app.yaml @@ -10,8 +10,8 @@ keywords: - media - comics - mangas -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -31,4 +31,4 @@ sources: - https://hub.docker.com/r/gotson/komga title: Komga train: community -version: 1.2.8 +version: 1.2.9 diff --git a/ix-dev/community/komga/templates/library/base_v2_1_8/container.py b/ix-dev/community/komga/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/komga/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/komga/templates/library/base_v2_1_8/ports.py b/ix-dev/community/komga/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/komga/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/komga/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/komga/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/komga/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/komga/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/komga/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/komga/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/komga/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/komga/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/komga/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/komga/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/komga/templates/library/base_v2_1_8/configs.py b/ix-dev/community/komga/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/komga/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/komga/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/komga/templates/library/base_v2_1_9/container.py b/ix-dev/community/komga/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/komga/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/komga/templates/library/base_v2_1_8/depends.py b/ix-dev/community/komga/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/komga/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/komga/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/komga/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/komga/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/komga/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/komga/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/komga/templates/library/base_v2_1_8/deps.py b/ix-dev/community/komga/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/komga/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/komga/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/komga/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/komga/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/komga/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/komga/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/komga/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/komga/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/komga/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/komga/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/komga/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/komga/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/komga/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/komga/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/komga/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/komga/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/komga/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/komga/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/komga/templates/library/base_v2_1_8/device.py b/ix-dev/community/komga/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/komga/templates/library/base_v2_1_8/device.py rename to ix-dev/community/komga/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/komga/templates/library/base_v2_1_8/devices.py b/ix-dev/community/komga/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/komga/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/komga/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/komga/templates/library/base_v2_1_8/dns.py b/ix-dev/community/komga/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/komga/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/komga/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/komga/templates/library/base_v2_1_8/environment.py b/ix-dev/community/komga/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/komga/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/komga/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/komga/templates/library/base_v2_1_8/error.py b/ix-dev/community/komga/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/komga/templates/library/base_v2_1_8/error.py rename to ix-dev/community/komga/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/komga/templates/library/base_v2_1_8/expose.py b/ix-dev/community/komga/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/komga/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/komga/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/komga/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/komga/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/komga/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/komga/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/komga/templates/library/base_v2_1_8/functions.py b/ix-dev/community/komga/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/komga/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/komga/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/komga/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/komga/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/komga/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/komga/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/komga/templates/library/base_v2_1_8/labels.py b/ix-dev/community/komga/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/komga/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/komga/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/komga/templates/library/base_v2_1_8/notes.py b/ix-dev/community/komga/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/komga/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/komga/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/komga/templates/library/base_v2_1_8/portal.py b/ix-dev/community/komga/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/komga/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/komga/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/komga/templates/library/base_v2_1_8/portals.py b/ix-dev/community/komga/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/komga/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/komga/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/komga/templates/library/base_v2_1_9/ports.py b/ix-dev/community/komga/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/komga/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/komga/templates/library/base_v2_1_8/render.py b/ix-dev/community/komga/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/komga/templates/library/base_v2_1_8/render.py rename to ix-dev/community/komga/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/komga/templates/library/base_v2_1_8/resources.py b/ix-dev/community/komga/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/komga/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/komga/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/komga/templates/library/base_v2_1_8/restart.py b/ix-dev/community/komga/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/komga/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/komga/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/komga/templates/library/base_v2_1_8/storage.py b/ix-dev/community/komga/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/komga/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/komga/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/komga/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/komga/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/komga/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/komga/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/komga/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/komga/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/komga/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/komga/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/komga/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/komga/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/komga/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/komga/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/komga/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/komga/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/komga/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/komga/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/komga/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/komga/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/komga/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/komga/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/komga/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/komga/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/komga/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/komga/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/komga/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/komga/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/komga/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/komga/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/komga/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/komga/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/komga/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/komga/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/komga/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/komga/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/komga/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/komga/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/komga/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/komga/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/komga/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/komga/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/komga/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/komga/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/komga/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/komga/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/komga/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/komga/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/komga/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/komga/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/komga/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/komga/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/komga/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/komga/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/komga/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/komga/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/komga/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/komga/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/komga/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/komga/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/komga/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/komga/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/komga/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/komga/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/komga/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/komga/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/komga/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/komga/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/komga/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/komga/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/komga/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/komga/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/komga/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/komga/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/komga/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/komga/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/komga/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/komga/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/komga/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/komga/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/komga/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/komga/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/komga/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/komga/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/komga/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/komga/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/komga/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/komga/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/komga/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/komga/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/komga/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/komga/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/komga/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/komga/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/komga/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/komga/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/komga/templates/library/base_v2_1_8/validations.py b/ix-dev/community/komga/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/komga/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/komga/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/komga/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/komga/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/komga/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/komga/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/komga/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/komga/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/komga/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/komga/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/komga/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/komga/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/komga/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/komga/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/komga/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/komga/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/komga/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/komga/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/komga/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/komga/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/komga/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/komga/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/lidarr/app.yaml b/ix-dev/community/lidarr/app.yaml index c30755c9e0..c6990ccd51 100644 --- a/ix-dev/community/lidarr/app.yaml +++ b/ix-dev/community/lidarr/app.yaml @@ -9,8 +9,8 @@ icon: https://media.sys.truenas.net/apps/lidarr/icons/icon.png keywords: - media - music -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -31,4 +31,4 @@ sources: - https://github.com/Lidarr/Lidarr title: Lidarr train: community -version: 1.2.12 +version: 1.2.13 diff --git a/ix-dev/community/lidarr/templates/library/base_v2_1_8/container.py b/ix-dev/community/lidarr/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/lidarr/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/lidarr/templates/library/base_v2_1_8/ports.py b/ix-dev/community/lidarr/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/lidarr/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/lidarr/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/lidarr/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/lidarr/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/lidarr/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/lidarr/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/lidarr/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/lidarr/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/lidarr/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/lidarr/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/lidarr/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/lidarr/templates/library/base_v2_1_8/configs.py b/ix-dev/community/lidarr/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/lidarr/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/lidarr/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/lidarr/templates/library/base_v2_1_9/container.py b/ix-dev/community/lidarr/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/lidarr/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/lidarr/templates/library/base_v2_1_8/depends.py b/ix-dev/community/lidarr/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/lidarr/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/lidarr/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/lidarr/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/lidarr/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/lidarr/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/lidarr/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/lidarr/templates/library/base_v2_1_8/deps.py b/ix-dev/community/lidarr/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/lidarr/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/lidarr/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/lidarr/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/lidarr/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/lidarr/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/lidarr/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/lidarr/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/lidarr/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/lidarr/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/lidarr/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/lidarr/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/lidarr/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/lidarr/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/lidarr/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/lidarr/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/lidarr/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/lidarr/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/lidarr/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/lidarr/templates/library/base_v2_1_8/device.py b/ix-dev/community/lidarr/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/lidarr/templates/library/base_v2_1_8/device.py rename to ix-dev/community/lidarr/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/lidarr/templates/library/base_v2_1_8/devices.py b/ix-dev/community/lidarr/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/lidarr/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/lidarr/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/lidarr/templates/library/base_v2_1_8/dns.py b/ix-dev/community/lidarr/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/lidarr/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/lidarr/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/lidarr/templates/library/base_v2_1_8/environment.py b/ix-dev/community/lidarr/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/lidarr/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/lidarr/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/lidarr/templates/library/base_v2_1_8/error.py b/ix-dev/community/lidarr/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/lidarr/templates/library/base_v2_1_8/error.py rename to ix-dev/community/lidarr/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/lidarr/templates/library/base_v2_1_8/expose.py b/ix-dev/community/lidarr/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/lidarr/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/lidarr/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/lidarr/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/lidarr/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/lidarr/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/lidarr/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/lidarr/templates/library/base_v2_1_8/functions.py b/ix-dev/community/lidarr/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/lidarr/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/lidarr/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/lidarr/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/lidarr/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/lidarr/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/lidarr/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/lidarr/templates/library/base_v2_1_8/labels.py b/ix-dev/community/lidarr/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/lidarr/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/lidarr/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/lidarr/templates/library/base_v2_1_8/notes.py b/ix-dev/community/lidarr/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/lidarr/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/lidarr/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/lidarr/templates/library/base_v2_1_8/portal.py b/ix-dev/community/lidarr/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/lidarr/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/lidarr/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/lidarr/templates/library/base_v2_1_8/portals.py b/ix-dev/community/lidarr/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/lidarr/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/lidarr/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/lidarr/templates/library/base_v2_1_9/ports.py b/ix-dev/community/lidarr/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/lidarr/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/lidarr/templates/library/base_v2_1_8/render.py b/ix-dev/community/lidarr/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/lidarr/templates/library/base_v2_1_8/render.py rename to ix-dev/community/lidarr/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/lidarr/templates/library/base_v2_1_8/resources.py b/ix-dev/community/lidarr/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/lidarr/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/lidarr/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/lidarr/templates/library/base_v2_1_8/restart.py b/ix-dev/community/lidarr/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/lidarr/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/lidarr/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/lidarr/templates/library/base_v2_1_8/storage.py b/ix-dev/community/lidarr/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/lidarr/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/lidarr/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/lidarr/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/lidarr/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/lidarr/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/lidarr/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/lidarr/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/lidarr/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/lidarr/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/lidarr/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/lidarr/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/lidarr/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/lidarr/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/lidarr/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/lidarr/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/lidarr/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/lidarr/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/lidarr/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/lidarr/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/lidarr/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/lidarr/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/lidarr/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/lidarr/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/lidarr/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/lidarr/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/lidarr/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/lidarr/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/lidarr/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/lidarr/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/lidarr/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/lidarr/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/lidarr/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/lidarr/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/lidarr/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/lidarr/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/lidarr/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/lidarr/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/lidarr/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/lidarr/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/lidarr/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/lidarr/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/lidarr/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/lidarr/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/lidarr/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/lidarr/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/lidarr/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/lidarr/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/lidarr/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/lidarr/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/lidarr/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/lidarr/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/lidarr/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/lidarr/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/lidarr/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/lidarr/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/lidarr/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/lidarr/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/lidarr/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/lidarr/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/lidarr/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/lidarr/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/lidarr/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/lidarr/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/lidarr/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/lidarr/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/lidarr/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/lidarr/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/lidarr/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/lidarr/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/lidarr/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/lidarr/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/lidarr/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/lidarr/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/lidarr/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/lidarr/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/lidarr/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/lidarr/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/lidarr/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/lidarr/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/lidarr/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/lidarr/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/lidarr/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/lidarr/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/lidarr/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/lidarr/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/lidarr/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/lidarr/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/lidarr/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/lidarr/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/lidarr/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/lidarr/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/lidarr/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/lidarr/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/lidarr/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/lidarr/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/lidarr/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/lidarr/templates/library/base_v2_1_8/validations.py b/ix-dev/community/lidarr/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/lidarr/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/lidarr/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/lidarr/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/lidarr/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/lidarr/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/lidarr/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/lidarr/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/lidarr/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/lidarr/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/lidarr/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/lidarr/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/lidarr/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/lidarr/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/lidarr/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/lidarr/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/lidarr/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/lidarr/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/lidarr/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/lidarr/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/lidarr/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/lidarr/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/lidarr/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/linkding/app.yaml b/ix-dev/community/linkding/app.yaml index 667b46509a..064b72a6a9 100644 --- a/ix-dev/community/linkding/app.yaml +++ b/ix-dev/community/linkding/app.yaml @@ -8,8 +8,8 @@ host_mounts: [] icon: https://media.sys.truenas.net/apps/linkding/icons/icon.svg keywords: - bookmark -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -33,4 +33,4 @@ sources: - https://hub.docker.com/r/sissbruecker/linkding/ title: Linkding train: community -version: 1.2.4 +version: 1.2.5 diff --git a/ix-dev/community/linkding/templates/library/base_v2_1_8/container.py b/ix-dev/community/linkding/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/linkding/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/linkding/templates/library/base_v2_1_8/ports.py b/ix-dev/community/linkding/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/linkding/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/linkding/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/linkding/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/linkding/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/linkding/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/linkding/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/linkding/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/linkding/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/linkding/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/linkding/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/linkding/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/linkding/templates/library/base_v2_1_8/configs.py b/ix-dev/community/linkding/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/linkding/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/linkding/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/linkding/templates/library/base_v2_1_9/container.py b/ix-dev/community/linkding/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/linkding/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/linkding/templates/library/base_v2_1_8/depends.py b/ix-dev/community/linkding/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/linkding/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/linkding/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/linkding/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/linkding/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/linkding/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/linkding/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/linkding/templates/library/base_v2_1_8/deps.py b/ix-dev/community/linkding/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/linkding/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/linkding/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/linkding/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/linkding/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/linkding/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/linkding/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/linkding/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/linkding/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/linkding/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/linkding/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/linkding/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/linkding/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/linkding/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/linkding/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/linkding/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/linkding/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/linkding/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/linkding/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/linkding/templates/library/base_v2_1_8/device.py b/ix-dev/community/linkding/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/linkding/templates/library/base_v2_1_8/device.py rename to ix-dev/community/linkding/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/linkding/templates/library/base_v2_1_8/devices.py b/ix-dev/community/linkding/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/linkding/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/linkding/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/linkding/templates/library/base_v2_1_8/dns.py b/ix-dev/community/linkding/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/linkding/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/linkding/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/linkding/templates/library/base_v2_1_8/environment.py b/ix-dev/community/linkding/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/linkding/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/linkding/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/linkding/templates/library/base_v2_1_8/error.py b/ix-dev/community/linkding/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/linkding/templates/library/base_v2_1_8/error.py rename to ix-dev/community/linkding/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/linkding/templates/library/base_v2_1_8/expose.py b/ix-dev/community/linkding/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/linkding/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/linkding/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/linkding/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/linkding/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/linkding/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/linkding/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/linkding/templates/library/base_v2_1_8/functions.py b/ix-dev/community/linkding/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/linkding/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/linkding/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/linkding/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/linkding/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/linkding/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/linkding/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/linkding/templates/library/base_v2_1_8/labels.py b/ix-dev/community/linkding/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/linkding/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/linkding/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/linkding/templates/library/base_v2_1_8/notes.py b/ix-dev/community/linkding/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/linkding/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/linkding/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/linkding/templates/library/base_v2_1_8/portal.py b/ix-dev/community/linkding/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/linkding/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/linkding/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/linkding/templates/library/base_v2_1_8/portals.py b/ix-dev/community/linkding/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/linkding/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/linkding/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/linkding/templates/library/base_v2_1_9/ports.py b/ix-dev/community/linkding/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/linkding/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/linkding/templates/library/base_v2_1_8/render.py b/ix-dev/community/linkding/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/linkding/templates/library/base_v2_1_8/render.py rename to ix-dev/community/linkding/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/linkding/templates/library/base_v2_1_8/resources.py b/ix-dev/community/linkding/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/linkding/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/linkding/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/linkding/templates/library/base_v2_1_8/restart.py b/ix-dev/community/linkding/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/linkding/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/linkding/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/linkding/templates/library/base_v2_1_8/storage.py b/ix-dev/community/linkding/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/linkding/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/linkding/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/linkding/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/linkding/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/linkding/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/linkding/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/linkding/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/linkding/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/linkding/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/linkding/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/linkding/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/linkding/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/linkding/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/linkding/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/linkding/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/linkding/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/linkding/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/linkding/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/linkding/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/linkding/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/linkding/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/linkding/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/linkding/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/linkding/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/linkding/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/linkding/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/linkding/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/linkding/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/linkding/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/linkding/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/linkding/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/linkding/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/linkding/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/linkding/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/linkding/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/linkding/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/linkding/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/linkding/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/linkding/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/linkding/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/linkding/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/linkding/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/linkding/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/linkding/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/linkding/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/linkding/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/linkding/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/linkding/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/linkding/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/linkding/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/linkding/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/linkding/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/linkding/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/linkding/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/linkding/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/linkding/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/linkding/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/linkding/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/linkding/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/linkding/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/linkding/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/linkding/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/linkding/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/linkding/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/linkding/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/linkding/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/linkding/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/linkding/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/linkding/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/linkding/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/linkding/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/linkding/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/linkding/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/linkding/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/linkding/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/linkding/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/linkding/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/linkding/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/linkding/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/linkding/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/linkding/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/linkding/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/linkding/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/linkding/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/linkding/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/linkding/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/linkding/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/linkding/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/linkding/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/linkding/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/linkding/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/linkding/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/linkding/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/linkding/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/linkding/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/linkding/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/linkding/templates/library/base_v2_1_8/validations.py b/ix-dev/community/linkding/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/linkding/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/linkding/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/linkding/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/linkding/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/linkding/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/linkding/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/linkding/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/linkding/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/linkding/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/linkding/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/linkding/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/linkding/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/linkding/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/linkding/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/linkding/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/linkding/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/linkding/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/linkding/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/linkding/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/linkding/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/linkding/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/linkding/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/listmonk/app.yaml b/ix-dev/community/listmonk/app.yaml index 4316425e51..e81d9728c3 100644 --- a/ix-dev/community/listmonk/app.yaml +++ b/ix-dev/community/listmonk/app.yaml @@ -19,8 +19,8 @@ icon: https://media.sys.truenas.net/apps/listmonk/icons/icon.svg keywords: - mailing-list - newsletter -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -46,4 +46,4 @@ sources: - https://github.com/knadh/listmonk title: Listmonk train: community -version: 1.2.4 +version: 1.2.5 diff --git a/ix-dev/community/listmonk/templates/library/base_v2_1_8/container.py b/ix-dev/community/listmonk/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/listmonk/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/listmonk/templates/library/base_v2_1_8/ports.py b/ix-dev/community/listmonk/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/listmonk/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/listmonk/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/listmonk/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/listmonk/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/listmonk/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/listmonk/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/listmonk/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/listmonk/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/listmonk/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/listmonk/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/listmonk/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/listmonk/templates/library/base_v2_1_8/configs.py b/ix-dev/community/listmonk/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/listmonk/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/listmonk/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/listmonk/templates/library/base_v2_1_9/container.py b/ix-dev/community/listmonk/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/listmonk/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/listmonk/templates/library/base_v2_1_8/depends.py b/ix-dev/community/listmonk/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/listmonk/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/listmonk/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/listmonk/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/listmonk/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/listmonk/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/listmonk/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/listmonk/templates/library/base_v2_1_8/deps.py b/ix-dev/community/listmonk/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/listmonk/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/listmonk/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/listmonk/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/listmonk/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/listmonk/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/listmonk/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/listmonk/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/listmonk/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/listmonk/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/listmonk/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/listmonk/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/listmonk/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/listmonk/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/listmonk/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/listmonk/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/listmonk/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/listmonk/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/listmonk/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/listmonk/templates/library/base_v2_1_8/device.py b/ix-dev/community/listmonk/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/listmonk/templates/library/base_v2_1_8/device.py rename to ix-dev/community/listmonk/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/listmonk/templates/library/base_v2_1_8/devices.py b/ix-dev/community/listmonk/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/listmonk/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/listmonk/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/listmonk/templates/library/base_v2_1_8/dns.py b/ix-dev/community/listmonk/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/listmonk/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/listmonk/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/listmonk/templates/library/base_v2_1_8/environment.py b/ix-dev/community/listmonk/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/listmonk/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/listmonk/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/listmonk/templates/library/base_v2_1_8/error.py b/ix-dev/community/listmonk/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/listmonk/templates/library/base_v2_1_8/error.py rename to ix-dev/community/listmonk/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/listmonk/templates/library/base_v2_1_8/expose.py b/ix-dev/community/listmonk/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/listmonk/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/listmonk/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/listmonk/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/listmonk/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/listmonk/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/listmonk/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/listmonk/templates/library/base_v2_1_8/functions.py b/ix-dev/community/listmonk/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/listmonk/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/listmonk/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/listmonk/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/listmonk/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/listmonk/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/listmonk/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/listmonk/templates/library/base_v2_1_8/labels.py b/ix-dev/community/listmonk/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/listmonk/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/listmonk/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/listmonk/templates/library/base_v2_1_8/notes.py b/ix-dev/community/listmonk/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/listmonk/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/listmonk/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/listmonk/templates/library/base_v2_1_8/portal.py b/ix-dev/community/listmonk/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/listmonk/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/listmonk/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/listmonk/templates/library/base_v2_1_8/portals.py b/ix-dev/community/listmonk/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/listmonk/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/listmonk/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/listmonk/templates/library/base_v2_1_9/ports.py b/ix-dev/community/listmonk/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/listmonk/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/listmonk/templates/library/base_v2_1_8/render.py b/ix-dev/community/listmonk/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/listmonk/templates/library/base_v2_1_8/render.py rename to ix-dev/community/listmonk/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/listmonk/templates/library/base_v2_1_8/resources.py b/ix-dev/community/listmonk/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/listmonk/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/listmonk/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/listmonk/templates/library/base_v2_1_8/restart.py b/ix-dev/community/listmonk/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/listmonk/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/listmonk/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/listmonk/templates/library/base_v2_1_8/storage.py b/ix-dev/community/listmonk/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/listmonk/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/listmonk/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/listmonk/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/listmonk/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/listmonk/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/listmonk/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/listmonk/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/listmonk/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/listmonk/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/listmonk/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/listmonk/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/listmonk/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/listmonk/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/listmonk/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/listmonk/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/listmonk/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/listmonk/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/listmonk/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/listmonk/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/listmonk/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/listmonk/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/listmonk/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/listmonk/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/listmonk/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/listmonk/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/listmonk/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/listmonk/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/listmonk/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/listmonk/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/listmonk/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/listmonk/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/listmonk/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/listmonk/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/listmonk/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/listmonk/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/listmonk/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/listmonk/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/listmonk/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/listmonk/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/listmonk/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/listmonk/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/listmonk/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/listmonk/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/listmonk/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/listmonk/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/listmonk/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/listmonk/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/listmonk/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/listmonk/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/listmonk/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/listmonk/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/listmonk/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/listmonk/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/listmonk/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/listmonk/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/listmonk/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/listmonk/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/listmonk/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/listmonk/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/listmonk/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/listmonk/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/listmonk/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/listmonk/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/listmonk/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/listmonk/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/listmonk/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/listmonk/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/listmonk/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/listmonk/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/listmonk/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/listmonk/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/listmonk/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/listmonk/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/listmonk/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/listmonk/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/listmonk/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/listmonk/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/listmonk/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/listmonk/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/listmonk/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/listmonk/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/listmonk/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/listmonk/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/listmonk/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/listmonk/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/listmonk/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/listmonk/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/listmonk/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/listmonk/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/listmonk/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/listmonk/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/listmonk/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/listmonk/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/listmonk/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/listmonk/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/listmonk/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/listmonk/templates/library/base_v2_1_8/validations.py b/ix-dev/community/listmonk/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/listmonk/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/listmonk/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/listmonk/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/listmonk/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/listmonk/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/listmonk/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/listmonk/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/listmonk/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/listmonk/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/listmonk/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/listmonk/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/listmonk/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/listmonk/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/listmonk/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/listmonk/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/listmonk/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/listmonk/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/listmonk/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/listmonk/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/listmonk/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/listmonk/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/listmonk/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/logseq/app.yaml b/ix-dev/community/logseq/app.yaml index 31e7c8993a..e5b27afb10 100644 --- a/ix-dev/community/logseq/app.yaml +++ b/ix-dev/community/logseq/app.yaml @@ -10,8 +10,8 @@ icon: https://media.sys.truenas.net/apps/logseq/icons/icon.png keywords: - knowledge - management -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -28,4 +28,4 @@ sources: - https://github.com/logseq/logseq title: Logseq train: community -version: 1.1.5 +version: 1.1.6 diff --git a/ix-dev/community/logseq/templates/library/base_v2_1_8/container.py b/ix-dev/community/logseq/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/logseq/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/logseq/templates/library/base_v2_1_8/ports.py b/ix-dev/community/logseq/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/logseq/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/logseq/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/logseq/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/logseq/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/logseq/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/logseq/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/logseq/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/logseq/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/logseq/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/logseq/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/logseq/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/logseq/templates/library/base_v2_1_8/configs.py b/ix-dev/community/logseq/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/logseq/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/logseq/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/logseq/templates/library/base_v2_1_9/container.py b/ix-dev/community/logseq/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/logseq/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/logseq/templates/library/base_v2_1_8/depends.py b/ix-dev/community/logseq/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/logseq/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/logseq/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/logseq/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/logseq/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/logseq/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/logseq/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/logseq/templates/library/base_v2_1_8/deps.py b/ix-dev/community/logseq/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/logseq/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/logseq/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/logseq/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/logseq/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/logseq/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/logseq/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/logseq/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/logseq/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/logseq/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/logseq/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/logseq/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/logseq/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/logseq/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/logseq/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/logseq/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/logseq/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/logseq/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/logseq/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/logseq/templates/library/base_v2_1_8/device.py b/ix-dev/community/logseq/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/logseq/templates/library/base_v2_1_8/device.py rename to ix-dev/community/logseq/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/logseq/templates/library/base_v2_1_8/devices.py b/ix-dev/community/logseq/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/logseq/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/logseq/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/logseq/templates/library/base_v2_1_8/dns.py b/ix-dev/community/logseq/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/logseq/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/logseq/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/logseq/templates/library/base_v2_1_8/environment.py b/ix-dev/community/logseq/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/logseq/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/logseq/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/logseq/templates/library/base_v2_1_8/error.py b/ix-dev/community/logseq/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/logseq/templates/library/base_v2_1_8/error.py rename to ix-dev/community/logseq/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/logseq/templates/library/base_v2_1_8/expose.py b/ix-dev/community/logseq/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/logseq/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/logseq/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/logseq/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/logseq/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/logseq/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/logseq/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/logseq/templates/library/base_v2_1_8/functions.py b/ix-dev/community/logseq/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/logseq/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/logseq/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/logseq/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/logseq/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/logseq/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/logseq/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/logseq/templates/library/base_v2_1_8/labels.py b/ix-dev/community/logseq/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/logseq/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/logseq/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/logseq/templates/library/base_v2_1_8/notes.py b/ix-dev/community/logseq/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/logseq/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/logseq/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/logseq/templates/library/base_v2_1_8/portal.py b/ix-dev/community/logseq/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/logseq/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/logseq/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/logseq/templates/library/base_v2_1_8/portals.py b/ix-dev/community/logseq/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/logseq/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/logseq/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/logseq/templates/library/base_v2_1_9/ports.py b/ix-dev/community/logseq/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/logseq/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/logseq/templates/library/base_v2_1_8/render.py b/ix-dev/community/logseq/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/logseq/templates/library/base_v2_1_8/render.py rename to ix-dev/community/logseq/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/logseq/templates/library/base_v2_1_8/resources.py b/ix-dev/community/logseq/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/logseq/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/logseq/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/logseq/templates/library/base_v2_1_8/restart.py b/ix-dev/community/logseq/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/logseq/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/logseq/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/logseq/templates/library/base_v2_1_8/storage.py b/ix-dev/community/logseq/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/logseq/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/logseq/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/logseq/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/logseq/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/logseq/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/logseq/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/logseq/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/logseq/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/logseq/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/logseq/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/logseq/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/logseq/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/logseq/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/logseq/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/logseq/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/logseq/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/logseq/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/logseq/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/logseq/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/logseq/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/logseq/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/logseq/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/logseq/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/logseq/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/logseq/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/logseq/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/logseq/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/logseq/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/logseq/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/logseq/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/logseq/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/logseq/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/logseq/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/logseq/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/logseq/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/logseq/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/logseq/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/logseq/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/logseq/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/logseq/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/logseq/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/logseq/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/logseq/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/logseq/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/logseq/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/logseq/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/logseq/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/logseq/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/logseq/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/logseq/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/logseq/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/logseq/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/logseq/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/logseq/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/logseq/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/logseq/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/logseq/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/logseq/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/logseq/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/logseq/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/logseq/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/logseq/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/logseq/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/logseq/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/logseq/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/logseq/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/logseq/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/logseq/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/logseq/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/logseq/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/logseq/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/logseq/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/logseq/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/logseq/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/logseq/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/logseq/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/logseq/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/logseq/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/logseq/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/logseq/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/logseq/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/logseq/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/logseq/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/logseq/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/logseq/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/logseq/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/logseq/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/logseq/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/logseq/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/logseq/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/logseq/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/logseq/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/logseq/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/logseq/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/logseq/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/logseq/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/logseq/templates/library/base_v2_1_8/validations.py b/ix-dev/community/logseq/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/logseq/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/logseq/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/logseq/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/logseq/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/logseq/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/logseq/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/logseq/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/logseq/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/logseq/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/logseq/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/logseq/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/logseq/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/logseq/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/logseq/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/logseq/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/logseq/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/logseq/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/logseq/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/logseq/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/logseq/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/logseq/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/logseq/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/lyrion-music-server/app.yaml b/ix-dev/community/lyrion-music-server/app.yaml index 2c82279c3b..50397d73c9 100644 --- a/ix-dev/community/lyrion-music-server/app.yaml +++ b/ix-dev/community/lyrion-music-server/app.yaml @@ -17,8 +17,8 @@ keywords: - entertainment - music - streaming -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -36,4 +36,4 @@ sources: - https://github.com/lms-community/slimserver title: Lyrion Music Server train: community -version: 1.0.2 +version: 1.0.3 diff --git a/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/container.py b/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/ports.py b/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/configs.py b/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/container.py b/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/depends.py b/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/deps.py b/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/device.py b/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/device.py rename to ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/devices.py b/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/dns.py b/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/environment.py b/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/error.py b/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/error.py rename to ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/expose.py b/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/functions.py b/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/labels.py b/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/notes.py b/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/portal.py b/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/portals.py b/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/ports.py b/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/render.py b/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/render.py rename to ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/resources.py b/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/restart.py b/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/storage.py b/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/validations.py b/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/lyrion-music-server/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/lyrion-music-server/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/mealie/app.yaml b/ix-dev/community/mealie/app.yaml index d3684f7d6a..e3b541d121 100644 --- a/ix-dev/community/mealie/app.yaml +++ b/ix-dev/community/mealie/app.yaml @@ -9,8 +9,8 @@ icon: https://media.sys.truenas.net/apps/mealie/icons/icon.png keywords: - recipes - meal planner -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -33,4 +33,4 @@ sources: - https://docs.mealie.io/ title: Mealie train: community -version: 1.4.7 +version: 1.4.8 diff --git a/ix-dev/community/mealie/templates/library/base_v2_1_8/container.py b/ix-dev/community/mealie/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/mealie/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/mealie/templates/library/base_v2_1_8/ports.py b/ix-dev/community/mealie/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/mealie/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/mealie/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/mealie/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/mealie/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/mealie/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/mealie/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/mealie/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/mealie/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/mealie/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/mealie/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/mealie/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/mealie/templates/library/base_v2_1_8/configs.py b/ix-dev/community/mealie/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/mealie/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/mealie/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/mealie/templates/library/base_v2_1_9/container.py b/ix-dev/community/mealie/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/mealie/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/mealie/templates/library/base_v2_1_8/depends.py b/ix-dev/community/mealie/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/mealie/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/mealie/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/mealie/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/mealie/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/mealie/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/mealie/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/mealie/templates/library/base_v2_1_8/deps.py b/ix-dev/community/mealie/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/mealie/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/mealie/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/mealie/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/mealie/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/mealie/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/mealie/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/mealie/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/mealie/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/mealie/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/mealie/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/mealie/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/mealie/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/mealie/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/mealie/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/mealie/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/mealie/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/mealie/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/mealie/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/mealie/templates/library/base_v2_1_8/device.py b/ix-dev/community/mealie/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/mealie/templates/library/base_v2_1_8/device.py rename to ix-dev/community/mealie/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/mealie/templates/library/base_v2_1_8/devices.py b/ix-dev/community/mealie/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/mealie/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/mealie/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/mealie/templates/library/base_v2_1_8/dns.py b/ix-dev/community/mealie/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/mealie/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/mealie/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/mealie/templates/library/base_v2_1_8/environment.py b/ix-dev/community/mealie/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/mealie/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/mealie/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/mealie/templates/library/base_v2_1_8/error.py b/ix-dev/community/mealie/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/mealie/templates/library/base_v2_1_8/error.py rename to ix-dev/community/mealie/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/mealie/templates/library/base_v2_1_8/expose.py b/ix-dev/community/mealie/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/mealie/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/mealie/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/mealie/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/mealie/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/mealie/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/mealie/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/mealie/templates/library/base_v2_1_8/functions.py b/ix-dev/community/mealie/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/mealie/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/mealie/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/mealie/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/mealie/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/mealie/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/mealie/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/mealie/templates/library/base_v2_1_8/labels.py b/ix-dev/community/mealie/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/mealie/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/mealie/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/mealie/templates/library/base_v2_1_8/notes.py b/ix-dev/community/mealie/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/mealie/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/mealie/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/mealie/templates/library/base_v2_1_8/portal.py b/ix-dev/community/mealie/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/mealie/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/mealie/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/mealie/templates/library/base_v2_1_8/portals.py b/ix-dev/community/mealie/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/mealie/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/mealie/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/mealie/templates/library/base_v2_1_9/ports.py b/ix-dev/community/mealie/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/mealie/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/mealie/templates/library/base_v2_1_8/render.py b/ix-dev/community/mealie/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/mealie/templates/library/base_v2_1_8/render.py rename to ix-dev/community/mealie/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/mealie/templates/library/base_v2_1_8/resources.py b/ix-dev/community/mealie/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/mealie/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/mealie/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/mealie/templates/library/base_v2_1_8/restart.py b/ix-dev/community/mealie/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/mealie/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/mealie/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/mealie/templates/library/base_v2_1_8/storage.py b/ix-dev/community/mealie/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/mealie/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/mealie/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/mealie/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/mealie/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/mealie/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/mealie/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/mealie/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/mealie/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/mealie/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/mealie/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/mealie/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/mealie/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/mealie/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/mealie/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/mealie/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/mealie/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/mealie/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/mealie/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/mealie/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/mealie/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/mealie/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/mealie/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/mealie/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/mealie/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/mealie/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/mealie/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/mealie/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/mealie/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/mealie/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/mealie/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/mealie/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/mealie/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/mealie/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/mealie/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/mealie/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/mealie/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/mealie/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/mealie/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/mealie/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/mealie/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/mealie/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/mealie/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/mealie/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/mealie/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/mealie/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/mealie/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/mealie/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/mealie/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/mealie/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/mealie/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/mealie/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/mealie/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/mealie/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/mealie/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/mealie/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/mealie/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/mealie/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/mealie/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/mealie/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/mealie/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/mealie/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/mealie/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/mealie/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/mealie/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/mealie/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/mealie/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/mealie/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/mealie/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/mealie/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/mealie/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/mealie/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/mealie/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/mealie/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/mealie/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/mealie/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/mealie/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/mealie/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/mealie/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/mealie/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/mealie/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/mealie/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/mealie/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/mealie/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/mealie/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/mealie/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/mealie/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/mealie/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/mealie/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/mealie/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/mealie/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/mealie/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/mealie/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/mealie/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/mealie/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/mealie/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/mealie/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/mealie/templates/library/base_v2_1_8/validations.py b/ix-dev/community/mealie/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/mealie/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/mealie/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/mealie/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/mealie/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/mealie/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/mealie/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/mealie/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/mealie/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/mealie/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/mealie/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/mealie/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/mealie/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/mealie/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/mealie/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/mealie/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/mealie/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/mealie/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/mealie/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/mealie/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/mealie/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/mealie/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/mealie/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/metube/app.yaml b/ix-dev/community/metube/app.yaml index f2f70bf6fa..790b4c1160 100644 --- a/ix-dev/community/metube/app.yaml +++ b/ix-dev/community/metube/app.yaml @@ -10,8 +10,8 @@ icon: https://media.sys.truenas.net/apps/metube/icons/icon.svg keywords: - youtube-dl - yt-dlp -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -30,4 +30,4 @@ sources: - https://github.com/alexta69/metube title: MeTube train: community -version: 1.2.13 +version: 1.2.14 diff --git a/ix-dev/community/metube/templates/library/base_v2_1_8/container.py b/ix-dev/community/metube/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/metube/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/metube/templates/library/base_v2_1_8/ports.py b/ix-dev/community/metube/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/metube/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/metube/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/metube/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/metube/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/metube/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/metube/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/metube/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/metube/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/metube/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/metube/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/metube/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/metube/templates/library/base_v2_1_8/configs.py b/ix-dev/community/metube/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/metube/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/metube/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/metube/templates/library/base_v2_1_9/container.py b/ix-dev/community/metube/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/metube/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/metube/templates/library/base_v2_1_8/depends.py b/ix-dev/community/metube/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/metube/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/metube/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/metube/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/metube/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/metube/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/metube/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/metube/templates/library/base_v2_1_8/deps.py b/ix-dev/community/metube/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/metube/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/metube/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/metube/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/metube/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/metube/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/metube/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/metube/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/metube/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/metube/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/metube/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/metube/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/metube/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/metube/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/metube/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/metube/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/metube/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/metube/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/metube/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/metube/templates/library/base_v2_1_8/device.py b/ix-dev/community/metube/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/metube/templates/library/base_v2_1_8/device.py rename to ix-dev/community/metube/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/metube/templates/library/base_v2_1_8/devices.py b/ix-dev/community/metube/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/metube/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/metube/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/metube/templates/library/base_v2_1_8/dns.py b/ix-dev/community/metube/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/metube/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/metube/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/metube/templates/library/base_v2_1_8/environment.py b/ix-dev/community/metube/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/metube/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/metube/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/metube/templates/library/base_v2_1_8/error.py b/ix-dev/community/metube/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/metube/templates/library/base_v2_1_8/error.py rename to ix-dev/community/metube/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/metube/templates/library/base_v2_1_8/expose.py b/ix-dev/community/metube/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/metube/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/metube/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/metube/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/metube/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/metube/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/metube/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/metube/templates/library/base_v2_1_8/functions.py b/ix-dev/community/metube/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/metube/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/metube/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/metube/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/metube/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/metube/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/metube/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/metube/templates/library/base_v2_1_8/labels.py b/ix-dev/community/metube/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/metube/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/metube/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/metube/templates/library/base_v2_1_8/notes.py b/ix-dev/community/metube/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/metube/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/metube/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/metube/templates/library/base_v2_1_8/portal.py b/ix-dev/community/metube/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/metube/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/metube/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/metube/templates/library/base_v2_1_8/portals.py b/ix-dev/community/metube/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/metube/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/metube/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/metube/templates/library/base_v2_1_9/ports.py b/ix-dev/community/metube/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/metube/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/metube/templates/library/base_v2_1_8/render.py b/ix-dev/community/metube/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/metube/templates/library/base_v2_1_8/render.py rename to ix-dev/community/metube/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/metube/templates/library/base_v2_1_8/resources.py b/ix-dev/community/metube/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/metube/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/metube/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/metube/templates/library/base_v2_1_8/restart.py b/ix-dev/community/metube/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/metube/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/metube/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/metube/templates/library/base_v2_1_8/storage.py b/ix-dev/community/metube/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/metube/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/metube/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/metube/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/metube/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/metube/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/metube/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/metube/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/metube/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/metube/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/metube/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/metube/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/metube/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/metube/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/metube/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/metube/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/metube/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/metube/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/metube/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/metube/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/metube/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/metube/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/metube/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/metube/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/metube/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/metube/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/metube/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/metube/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/metube/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/metube/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/metube/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/metube/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/metube/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/metube/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/metube/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/metube/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/metube/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/metube/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/metube/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/metube/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/metube/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/metube/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/metube/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/metube/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/metube/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/metube/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/metube/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/metube/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/metube/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/metube/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/metube/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/metube/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/metube/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/metube/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/metube/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/metube/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/metube/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/metube/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/metube/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/metube/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/metube/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/metube/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/metube/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/metube/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/metube/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/metube/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/metube/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/metube/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/metube/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/metube/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/metube/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/metube/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/metube/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/metube/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/metube/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/metube/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/metube/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/metube/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/metube/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/metube/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/metube/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/metube/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/metube/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/metube/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/metube/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/metube/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/metube/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/metube/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/metube/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/metube/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/metube/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/metube/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/metube/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/metube/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/metube/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/metube/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/metube/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/metube/templates/library/base_v2_1_8/validations.py b/ix-dev/community/metube/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/metube/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/metube/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/metube/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/metube/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/metube/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/metube/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/metube/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/metube/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/metube/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/metube/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/metube/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/metube/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/metube/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/metube/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/metube/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/metube/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/metube/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/metube/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/metube/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/metube/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/metube/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/metube/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/minecraft/app.yaml b/ix-dev/community/minecraft/app.yaml index 5deb8aaf56..3c3ad5b78c 100644 --- a/ix-dev/community/minecraft/app.yaml +++ b/ix-dev/community/minecraft/app.yaml @@ -19,8 +19,8 @@ icon: https://media.sys.truenas.net/apps/minecraft/icons/icon.svg keywords: - world - building -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -38,4 +38,4 @@ sources: - https://github.com/itzg/docker-minecraft-server title: Minecraft train: community -version: 1.12.8 +version: 1.12.9 diff --git a/ix-dev/community/minecraft/templates/library/base_v2_1_8/container.py b/ix-dev/community/minecraft/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/minecraft/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/minecraft/templates/library/base_v2_1_8/ports.py b/ix-dev/community/minecraft/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/minecraft/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/minecraft/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/minecraft/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/minecraft/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/minecraft/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/minecraft/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/minecraft/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/minecraft/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/minecraft/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/minecraft/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/minecraft/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/minecraft/templates/library/base_v2_1_8/configs.py b/ix-dev/community/minecraft/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/minecraft/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/minecraft/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/minecraft/templates/library/base_v2_1_9/container.py b/ix-dev/community/minecraft/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/minecraft/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/minecraft/templates/library/base_v2_1_8/depends.py b/ix-dev/community/minecraft/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/minecraft/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/minecraft/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/minecraft/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/minecraft/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/minecraft/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/minecraft/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/minecraft/templates/library/base_v2_1_8/deps.py b/ix-dev/community/minecraft/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/minecraft/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/minecraft/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/minecraft/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/minecraft/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/minecraft/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/minecraft/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/minecraft/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/minecraft/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/minecraft/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/minecraft/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/minecraft/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/minecraft/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/minecraft/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/minecraft/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/minecraft/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/minecraft/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/minecraft/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/minecraft/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/minecraft/templates/library/base_v2_1_8/device.py b/ix-dev/community/minecraft/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/minecraft/templates/library/base_v2_1_8/device.py rename to ix-dev/community/minecraft/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/minecraft/templates/library/base_v2_1_8/devices.py b/ix-dev/community/minecraft/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/minecraft/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/minecraft/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/minecraft/templates/library/base_v2_1_8/dns.py b/ix-dev/community/minecraft/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/minecraft/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/minecraft/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/minecraft/templates/library/base_v2_1_8/environment.py b/ix-dev/community/minecraft/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/minecraft/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/minecraft/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/minecraft/templates/library/base_v2_1_8/error.py b/ix-dev/community/minecraft/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/minecraft/templates/library/base_v2_1_8/error.py rename to ix-dev/community/minecraft/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/minecraft/templates/library/base_v2_1_8/expose.py b/ix-dev/community/minecraft/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/minecraft/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/minecraft/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/minecraft/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/minecraft/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/minecraft/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/minecraft/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/minecraft/templates/library/base_v2_1_8/functions.py b/ix-dev/community/minecraft/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/minecraft/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/minecraft/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/minecraft/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/minecraft/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/minecraft/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/minecraft/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/minecraft/templates/library/base_v2_1_8/labels.py b/ix-dev/community/minecraft/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/minecraft/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/minecraft/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/minecraft/templates/library/base_v2_1_8/notes.py b/ix-dev/community/minecraft/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/minecraft/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/minecraft/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/minecraft/templates/library/base_v2_1_8/portal.py b/ix-dev/community/minecraft/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/minecraft/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/minecraft/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/minecraft/templates/library/base_v2_1_8/portals.py b/ix-dev/community/minecraft/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/minecraft/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/minecraft/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/minecraft/templates/library/base_v2_1_9/ports.py b/ix-dev/community/minecraft/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/minecraft/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/minecraft/templates/library/base_v2_1_8/render.py b/ix-dev/community/minecraft/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/minecraft/templates/library/base_v2_1_8/render.py rename to ix-dev/community/minecraft/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/minecraft/templates/library/base_v2_1_8/resources.py b/ix-dev/community/minecraft/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/minecraft/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/minecraft/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/minecraft/templates/library/base_v2_1_8/restart.py b/ix-dev/community/minecraft/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/minecraft/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/minecraft/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/minecraft/templates/library/base_v2_1_8/storage.py b/ix-dev/community/minecraft/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/minecraft/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/minecraft/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/minecraft/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/minecraft/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/minecraft/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/minecraft/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/minecraft/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/minecraft/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/minecraft/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/minecraft/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/minecraft/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/minecraft/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/minecraft/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/minecraft/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/minecraft/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/minecraft/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/minecraft/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/minecraft/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/minecraft/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/minecraft/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/minecraft/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/minecraft/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/minecraft/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/minecraft/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/minecraft/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/minecraft/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/minecraft/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/minecraft/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/minecraft/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/minecraft/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/minecraft/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/minecraft/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/minecraft/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/minecraft/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/minecraft/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/minecraft/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/minecraft/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/minecraft/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/minecraft/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/minecraft/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/minecraft/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/minecraft/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/minecraft/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/minecraft/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/minecraft/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/minecraft/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/minecraft/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/minecraft/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/minecraft/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/minecraft/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/minecraft/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/minecraft/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/minecraft/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/minecraft/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/minecraft/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/minecraft/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/minecraft/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/minecraft/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/minecraft/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/minecraft/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/minecraft/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/minecraft/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/minecraft/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/minecraft/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/minecraft/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/minecraft/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/minecraft/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/minecraft/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/minecraft/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/minecraft/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/minecraft/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/minecraft/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/minecraft/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/minecraft/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/minecraft/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/minecraft/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/minecraft/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/minecraft/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/minecraft/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/minecraft/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/minecraft/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/minecraft/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/minecraft/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/minecraft/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/minecraft/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/minecraft/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/minecraft/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/minecraft/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/minecraft/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/minecraft/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/minecraft/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/minecraft/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/minecraft/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/minecraft/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/minecraft/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/minecraft/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/minecraft/templates/library/base_v2_1_8/validations.py b/ix-dev/community/minecraft/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/minecraft/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/minecraft/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/minecraft/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/minecraft/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/minecraft/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/minecraft/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/minecraft/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/minecraft/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/minecraft/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/minecraft/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/minecraft/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/minecraft/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/minecraft/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/minecraft/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/minecraft/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/minecraft/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/minecraft/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/minecraft/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/minecraft/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/minecraft/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/minecraft/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/minecraft/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/mineos/app.yaml b/ix-dev/community/mineos/app.yaml index 0b24e28bf8..913b95e80c 100644 --- a/ix-dev/community/mineos/app.yaml +++ b/ix-dev/community/mineos/app.yaml @@ -19,8 +19,8 @@ host_mounts: [] icon: https://media.sys.truenas.net/apps/mineos/icons/icon.png keywords: - minecraft -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -39,4 +39,4 @@ sources: - https://github.com/hexparrot/mineos-node title: MineOS train: community -version: 1.1.5 +version: 1.1.6 diff --git a/ix-dev/community/mineos/templates/library/base_v2_1_8/container.py b/ix-dev/community/mineos/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/mineos/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/mineos/templates/library/base_v2_1_8/ports.py b/ix-dev/community/mineos/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/mineos/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/mineos/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/mineos/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/mineos/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/mineos/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/mineos/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/mineos/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/mineos/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/mineos/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/mineos/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/mineos/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/mineos/templates/library/base_v2_1_8/configs.py b/ix-dev/community/mineos/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/mineos/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/mineos/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/mineos/templates/library/base_v2_1_9/container.py b/ix-dev/community/mineos/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/mineos/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/mineos/templates/library/base_v2_1_8/depends.py b/ix-dev/community/mineos/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/mineos/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/mineos/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/mineos/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/mineos/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/mineos/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/mineos/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/mineos/templates/library/base_v2_1_8/deps.py b/ix-dev/community/mineos/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/mineos/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/mineos/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/mineos/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/mineos/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/mineos/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/mineos/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/mineos/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/mineos/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/mineos/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/mineos/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/mineos/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/mineos/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/mineos/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/mineos/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/mineos/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/mineos/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/mineos/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/mineos/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/mineos/templates/library/base_v2_1_8/device.py b/ix-dev/community/mineos/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/mineos/templates/library/base_v2_1_8/device.py rename to ix-dev/community/mineos/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/mineos/templates/library/base_v2_1_8/devices.py b/ix-dev/community/mineos/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/mineos/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/mineos/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/mineos/templates/library/base_v2_1_8/dns.py b/ix-dev/community/mineos/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/mineos/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/mineos/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/mineos/templates/library/base_v2_1_8/environment.py b/ix-dev/community/mineos/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/mineos/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/mineos/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/mineos/templates/library/base_v2_1_8/error.py b/ix-dev/community/mineos/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/mineos/templates/library/base_v2_1_8/error.py rename to ix-dev/community/mineos/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/mineos/templates/library/base_v2_1_8/expose.py b/ix-dev/community/mineos/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/mineos/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/mineos/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/mineos/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/mineos/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/mineos/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/mineos/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/mineos/templates/library/base_v2_1_8/functions.py b/ix-dev/community/mineos/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/mineos/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/mineos/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/mineos/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/mineos/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/mineos/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/mineos/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/mineos/templates/library/base_v2_1_8/labels.py b/ix-dev/community/mineos/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/mineos/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/mineos/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/mineos/templates/library/base_v2_1_8/notes.py b/ix-dev/community/mineos/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/mineos/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/mineos/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/mineos/templates/library/base_v2_1_8/portal.py b/ix-dev/community/mineos/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/mineos/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/mineos/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/mineos/templates/library/base_v2_1_8/portals.py b/ix-dev/community/mineos/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/mineos/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/mineos/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/mineos/templates/library/base_v2_1_9/ports.py b/ix-dev/community/mineos/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/mineos/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/mineos/templates/library/base_v2_1_8/render.py b/ix-dev/community/mineos/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/mineos/templates/library/base_v2_1_8/render.py rename to ix-dev/community/mineos/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/mineos/templates/library/base_v2_1_8/resources.py b/ix-dev/community/mineos/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/mineos/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/mineos/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/mineos/templates/library/base_v2_1_8/restart.py b/ix-dev/community/mineos/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/mineos/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/mineos/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/mineos/templates/library/base_v2_1_8/storage.py b/ix-dev/community/mineos/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/mineos/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/mineos/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/mineos/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/mineos/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/mineos/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/mineos/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/mineos/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/mineos/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/mineos/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/mineos/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/mineos/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/mineos/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/mineos/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/mineos/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/mineos/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/mineos/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/mineos/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/mineos/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/mineos/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/mineos/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/mineos/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/mineos/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/mineos/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/mineos/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/mineos/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/mineos/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/mineos/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/mineos/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/mineos/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/mineos/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/mineos/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/mineos/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/mineos/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/mineos/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/mineos/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/mineos/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/mineos/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/mineos/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/mineos/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/mineos/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/mineos/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/mineos/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/mineos/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/mineos/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/mineos/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/mineos/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/mineos/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/mineos/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/mineos/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/mineos/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/mineos/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/mineos/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/mineos/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/mineos/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/mineos/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/mineos/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/mineos/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/mineos/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/mineos/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/mineos/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/mineos/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/mineos/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/mineos/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/mineos/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/mineos/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/mineos/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/mineos/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/mineos/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/mineos/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/mineos/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/mineos/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/mineos/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/mineos/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/mineos/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/mineos/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/mineos/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/mineos/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/mineos/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/mineos/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/mineos/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/mineos/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/mineos/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/mineos/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/mineos/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/mineos/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/mineos/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/mineos/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/mineos/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/mineos/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/mineos/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/mineos/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/mineos/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/mineos/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/mineos/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/mineos/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/mineos/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/mineos/templates/library/base_v2_1_8/validations.py b/ix-dev/community/mineos/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/mineos/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/mineos/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/mineos/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/mineos/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/mineos/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/mineos/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/mineos/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/mineos/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/mineos/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/mineos/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/mineos/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/mineos/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/mineos/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/mineos/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/mineos/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/mineos/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/mineos/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/mineos/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/mineos/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/mineos/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/mineos/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/mineos/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/mumble/app.yaml b/ix-dev/community/mumble/app.yaml index 3bb6dadc3b..0d1540dadb 100644 --- a/ix-dev/community/mumble/app.yaml +++ b/ix-dev/community/mumble/app.yaml @@ -8,8 +8,8 @@ host_mounts: [] icon: https://media.sys.truenas.net/apps/mumble/icons/icon.svg keywords: - voice -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -27,4 +27,4 @@ sources: - https://www.mumble.info/ title: Mumble train: community -version: 1.2.6 +version: 1.2.7 diff --git a/ix-dev/community/mumble/templates/library/base_v2_1_8/container.py b/ix-dev/community/mumble/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/mumble/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/mumble/templates/library/base_v2_1_8/ports.py b/ix-dev/community/mumble/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/mumble/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/mumble/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/mumble/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/mumble/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/mumble/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/mumble/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/mumble/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/mumble/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/mumble/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/mumble/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/mumble/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/mumble/templates/library/base_v2_1_8/configs.py b/ix-dev/community/mumble/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/mumble/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/mumble/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/mumble/templates/library/base_v2_1_9/container.py b/ix-dev/community/mumble/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/mumble/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/mumble/templates/library/base_v2_1_8/depends.py b/ix-dev/community/mumble/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/mumble/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/mumble/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/mumble/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/mumble/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/mumble/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/mumble/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/mumble/templates/library/base_v2_1_8/deps.py b/ix-dev/community/mumble/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/mumble/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/mumble/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/mumble/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/mumble/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/mumble/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/mumble/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/mumble/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/mumble/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/mumble/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/mumble/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/mumble/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/mumble/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/mumble/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/mumble/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/mumble/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/mumble/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/mumble/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/mumble/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/mumble/templates/library/base_v2_1_8/device.py b/ix-dev/community/mumble/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/mumble/templates/library/base_v2_1_8/device.py rename to ix-dev/community/mumble/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/mumble/templates/library/base_v2_1_8/devices.py b/ix-dev/community/mumble/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/mumble/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/mumble/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/mumble/templates/library/base_v2_1_8/dns.py b/ix-dev/community/mumble/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/mumble/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/mumble/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/mumble/templates/library/base_v2_1_8/environment.py b/ix-dev/community/mumble/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/mumble/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/mumble/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/mumble/templates/library/base_v2_1_8/error.py b/ix-dev/community/mumble/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/mumble/templates/library/base_v2_1_8/error.py rename to ix-dev/community/mumble/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/mumble/templates/library/base_v2_1_8/expose.py b/ix-dev/community/mumble/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/mumble/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/mumble/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/mumble/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/mumble/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/mumble/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/mumble/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/mumble/templates/library/base_v2_1_8/functions.py b/ix-dev/community/mumble/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/mumble/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/mumble/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/mumble/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/mumble/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/mumble/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/mumble/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/mumble/templates/library/base_v2_1_8/labels.py b/ix-dev/community/mumble/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/mumble/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/mumble/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/mumble/templates/library/base_v2_1_8/notes.py b/ix-dev/community/mumble/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/mumble/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/mumble/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/mumble/templates/library/base_v2_1_8/portal.py b/ix-dev/community/mumble/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/mumble/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/mumble/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/mumble/templates/library/base_v2_1_8/portals.py b/ix-dev/community/mumble/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/mumble/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/mumble/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/mumble/templates/library/base_v2_1_9/ports.py b/ix-dev/community/mumble/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/mumble/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/mumble/templates/library/base_v2_1_8/render.py b/ix-dev/community/mumble/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/mumble/templates/library/base_v2_1_8/render.py rename to ix-dev/community/mumble/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/mumble/templates/library/base_v2_1_8/resources.py b/ix-dev/community/mumble/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/mumble/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/mumble/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/mumble/templates/library/base_v2_1_8/restart.py b/ix-dev/community/mumble/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/mumble/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/mumble/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/mumble/templates/library/base_v2_1_8/storage.py b/ix-dev/community/mumble/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/mumble/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/mumble/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/mumble/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/mumble/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/mumble/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/mumble/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/mumble/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/mumble/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/mumble/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/mumble/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/mumble/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/mumble/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/mumble/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/mumble/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/mumble/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/mumble/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/mumble/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/mumble/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/mumble/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/mumble/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/mumble/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/mumble/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/mumble/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/mumble/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/mumble/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/mumble/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/mumble/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/mumble/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/mumble/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/mumble/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/mumble/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/mumble/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/mumble/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/mumble/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/mumble/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/mumble/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/mumble/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/mumble/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/mumble/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/mumble/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/mumble/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/mumble/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/mumble/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/mumble/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/mumble/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/mumble/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/mumble/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/mumble/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/mumble/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/mumble/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/mumble/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/mumble/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/mumble/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/mumble/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/mumble/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/mumble/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/mumble/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/mumble/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/mumble/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/mumble/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/mumble/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/mumble/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/mumble/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/mumble/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/mumble/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/mumble/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/mumble/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/mumble/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/mumble/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/mumble/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/mumble/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/mumble/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/mumble/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/mumble/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/mumble/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/mumble/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/mumble/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/mumble/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/mumble/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/mumble/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/mumble/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/mumble/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/mumble/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/mumble/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/mumble/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/mumble/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/mumble/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/mumble/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/mumble/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/mumble/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/mumble/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/mumble/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/mumble/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/mumble/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/mumble/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/mumble/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/mumble/templates/library/base_v2_1_8/validations.py b/ix-dev/community/mumble/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/mumble/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/mumble/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/mumble/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/mumble/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/mumble/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/mumble/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/mumble/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/mumble/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/mumble/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/mumble/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/mumble/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/mumble/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/mumble/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/mumble/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/mumble/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/mumble/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/mumble/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/mumble/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/mumble/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/mumble/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/mumble/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/mumble/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/n8n/app.yaml b/ix-dev/community/n8n/app.yaml index db3c605d15..25ca3e64a1 100644 --- a/ix-dev/community/n8n/app.yaml +++ b/ix-dev/community/n8n/app.yaml @@ -9,8 +9,8 @@ icon: https://media.sys.truenas.net/apps/n8n/icons/icon.png keywords: - workflows - automation -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -42,4 +42,4 @@ sources: - https://hub.docker.com/r/n8nio/n8n title: n8n train: community -version: 1.5.12 +version: 1.5.13 diff --git a/ix-dev/community/n8n/templates/library/base_v2_1_8/container.py b/ix-dev/community/n8n/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/n8n/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/n8n/templates/library/base_v2_1_8/ports.py b/ix-dev/community/n8n/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/n8n/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/n8n/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/n8n/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/n8n/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/n8n/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/n8n/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/n8n/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/n8n/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/n8n/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/n8n/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/n8n/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/n8n/templates/library/base_v2_1_8/configs.py b/ix-dev/community/n8n/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/n8n/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/n8n/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/n8n/templates/library/base_v2_1_9/container.py b/ix-dev/community/n8n/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/n8n/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/n8n/templates/library/base_v2_1_8/depends.py b/ix-dev/community/n8n/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/n8n/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/n8n/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/n8n/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/n8n/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/n8n/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/n8n/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/n8n/templates/library/base_v2_1_8/deps.py b/ix-dev/community/n8n/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/n8n/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/n8n/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/n8n/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/n8n/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/n8n/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/n8n/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/n8n/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/n8n/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/n8n/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/n8n/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/n8n/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/n8n/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/n8n/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/n8n/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/n8n/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/n8n/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/n8n/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/n8n/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/n8n/templates/library/base_v2_1_8/device.py b/ix-dev/community/n8n/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/n8n/templates/library/base_v2_1_8/device.py rename to ix-dev/community/n8n/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/n8n/templates/library/base_v2_1_8/devices.py b/ix-dev/community/n8n/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/n8n/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/n8n/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/n8n/templates/library/base_v2_1_8/dns.py b/ix-dev/community/n8n/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/n8n/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/n8n/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/n8n/templates/library/base_v2_1_8/environment.py b/ix-dev/community/n8n/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/n8n/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/n8n/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/n8n/templates/library/base_v2_1_8/error.py b/ix-dev/community/n8n/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/n8n/templates/library/base_v2_1_8/error.py rename to ix-dev/community/n8n/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/n8n/templates/library/base_v2_1_8/expose.py b/ix-dev/community/n8n/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/n8n/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/n8n/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/n8n/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/n8n/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/n8n/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/n8n/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/n8n/templates/library/base_v2_1_8/functions.py b/ix-dev/community/n8n/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/n8n/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/n8n/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/n8n/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/n8n/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/n8n/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/n8n/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/n8n/templates/library/base_v2_1_8/labels.py b/ix-dev/community/n8n/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/n8n/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/n8n/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/n8n/templates/library/base_v2_1_8/notes.py b/ix-dev/community/n8n/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/n8n/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/n8n/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/n8n/templates/library/base_v2_1_8/portal.py b/ix-dev/community/n8n/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/n8n/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/n8n/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/n8n/templates/library/base_v2_1_8/portals.py b/ix-dev/community/n8n/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/n8n/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/n8n/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/n8n/templates/library/base_v2_1_9/ports.py b/ix-dev/community/n8n/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/n8n/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/n8n/templates/library/base_v2_1_8/render.py b/ix-dev/community/n8n/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/n8n/templates/library/base_v2_1_8/render.py rename to ix-dev/community/n8n/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/n8n/templates/library/base_v2_1_8/resources.py b/ix-dev/community/n8n/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/n8n/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/n8n/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/n8n/templates/library/base_v2_1_8/restart.py b/ix-dev/community/n8n/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/n8n/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/n8n/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/n8n/templates/library/base_v2_1_8/storage.py b/ix-dev/community/n8n/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/n8n/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/n8n/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/n8n/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/n8n/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/n8n/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/n8n/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/n8n/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/n8n/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/n8n/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/n8n/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/n8n/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/n8n/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/n8n/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/n8n/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/n8n/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/n8n/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/n8n/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/n8n/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/n8n/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/n8n/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/n8n/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/n8n/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/n8n/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/n8n/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/n8n/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/n8n/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/n8n/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/n8n/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/n8n/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/n8n/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/n8n/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/n8n/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/n8n/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/n8n/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/n8n/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/n8n/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/n8n/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/n8n/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/n8n/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/n8n/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/n8n/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/n8n/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/n8n/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/n8n/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/n8n/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/n8n/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/n8n/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/n8n/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/n8n/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/n8n/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/n8n/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/n8n/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/n8n/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/n8n/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/n8n/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/n8n/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/n8n/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/n8n/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/n8n/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/n8n/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/n8n/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/n8n/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/n8n/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/n8n/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/n8n/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/n8n/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/n8n/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/n8n/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/n8n/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/n8n/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/n8n/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/n8n/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/n8n/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/n8n/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/n8n/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/n8n/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/n8n/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/n8n/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/n8n/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/n8n/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/n8n/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/n8n/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/n8n/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/n8n/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/n8n/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/n8n/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/n8n/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/n8n/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/n8n/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/n8n/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/n8n/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/n8n/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/n8n/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/n8n/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/n8n/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/n8n/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/n8n/templates/library/base_v2_1_8/validations.py b/ix-dev/community/n8n/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/n8n/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/n8n/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/n8n/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/n8n/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/n8n/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/n8n/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/n8n/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/n8n/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/n8n/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/n8n/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/n8n/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/n8n/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/n8n/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/n8n/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/n8n/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/n8n/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/n8n/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/n8n/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/n8n/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/n8n/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/n8n/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/n8n/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/navidrome/app.yaml b/ix-dev/community/navidrome/app.yaml index 0494ef3641..a01ba7e079 100644 --- a/ix-dev/community/navidrome/app.yaml +++ b/ix-dev/community/navidrome/app.yaml @@ -11,8 +11,8 @@ icon: https://media.sys.truenas.net/apps/navidrome/icons/icon.png keywords: - media - music -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -32,4 +32,4 @@ sources: - https://github.com/navidrome/navidrome/ title: Navidrome train: community -version: 1.1.7 +version: 1.1.8 diff --git a/ix-dev/community/navidrome/templates/library/base_v2_1_8/container.py b/ix-dev/community/navidrome/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/navidrome/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/navidrome/templates/library/base_v2_1_8/ports.py b/ix-dev/community/navidrome/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/navidrome/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/navidrome/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/navidrome/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/navidrome/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/navidrome/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/navidrome/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/navidrome/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/navidrome/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/navidrome/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/navidrome/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/navidrome/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/navidrome/templates/library/base_v2_1_8/configs.py b/ix-dev/community/navidrome/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/navidrome/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/navidrome/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/navidrome/templates/library/base_v2_1_9/container.py b/ix-dev/community/navidrome/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/navidrome/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/navidrome/templates/library/base_v2_1_8/depends.py b/ix-dev/community/navidrome/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/navidrome/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/navidrome/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/navidrome/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/navidrome/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/navidrome/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/navidrome/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/navidrome/templates/library/base_v2_1_8/deps.py b/ix-dev/community/navidrome/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/navidrome/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/navidrome/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/navidrome/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/navidrome/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/navidrome/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/navidrome/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/navidrome/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/navidrome/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/navidrome/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/navidrome/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/navidrome/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/navidrome/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/navidrome/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/navidrome/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/navidrome/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/navidrome/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/navidrome/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/navidrome/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/navidrome/templates/library/base_v2_1_8/device.py b/ix-dev/community/navidrome/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/navidrome/templates/library/base_v2_1_8/device.py rename to ix-dev/community/navidrome/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/navidrome/templates/library/base_v2_1_8/devices.py b/ix-dev/community/navidrome/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/navidrome/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/navidrome/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/navidrome/templates/library/base_v2_1_8/dns.py b/ix-dev/community/navidrome/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/navidrome/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/navidrome/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/navidrome/templates/library/base_v2_1_8/environment.py b/ix-dev/community/navidrome/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/navidrome/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/navidrome/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/navidrome/templates/library/base_v2_1_8/error.py b/ix-dev/community/navidrome/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/navidrome/templates/library/base_v2_1_8/error.py rename to ix-dev/community/navidrome/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/navidrome/templates/library/base_v2_1_8/expose.py b/ix-dev/community/navidrome/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/navidrome/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/navidrome/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/navidrome/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/navidrome/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/navidrome/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/navidrome/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/navidrome/templates/library/base_v2_1_8/functions.py b/ix-dev/community/navidrome/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/navidrome/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/navidrome/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/navidrome/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/navidrome/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/navidrome/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/navidrome/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/navidrome/templates/library/base_v2_1_8/labels.py b/ix-dev/community/navidrome/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/navidrome/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/navidrome/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/navidrome/templates/library/base_v2_1_8/notes.py b/ix-dev/community/navidrome/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/navidrome/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/navidrome/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/navidrome/templates/library/base_v2_1_8/portal.py b/ix-dev/community/navidrome/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/navidrome/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/navidrome/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/navidrome/templates/library/base_v2_1_8/portals.py b/ix-dev/community/navidrome/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/navidrome/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/navidrome/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/navidrome/templates/library/base_v2_1_9/ports.py b/ix-dev/community/navidrome/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/navidrome/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/navidrome/templates/library/base_v2_1_8/render.py b/ix-dev/community/navidrome/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/navidrome/templates/library/base_v2_1_8/render.py rename to ix-dev/community/navidrome/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/navidrome/templates/library/base_v2_1_8/resources.py b/ix-dev/community/navidrome/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/navidrome/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/navidrome/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/navidrome/templates/library/base_v2_1_8/restart.py b/ix-dev/community/navidrome/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/navidrome/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/navidrome/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/navidrome/templates/library/base_v2_1_8/storage.py b/ix-dev/community/navidrome/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/navidrome/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/navidrome/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/navidrome/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/navidrome/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/navidrome/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/navidrome/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/navidrome/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/navidrome/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/navidrome/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/navidrome/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/navidrome/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/navidrome/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/navidrome/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/navidrome/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/navidrome/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/navidrome/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/navidrome/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/navidrome/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/navidrome/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/navidrome/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/navidrome/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/navidrome/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/navidrome/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/navidrome/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/navidrome/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/navidrome/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/navidrome/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/navidrome/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/navidrome/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/navidrome/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/navidrome/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/navidrome/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/navidrome/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/navidrome/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/navidrome/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/navidrome/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/navidrome/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/navidrome/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/navidrome/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/navidrome/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/navidrome/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/navidrome/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/navidrome/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/navidrome/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/navidrome/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/navidrome/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/navidrome/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/navidrome/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/navidrome/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/navidrome/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/navidrome/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/navidrome/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/navidrome/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/navidrome/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/navidrome/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/navidrome/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/navidrome/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/navidrome/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/navidrome/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/navidrome/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/navidrome/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/navidrome/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/navidrome/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/navidrome/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/navidrome/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/navidrome/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/navidrome/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/navidrome/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/navidrome/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/navidrome/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/navidrome/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/navidrome/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/navidrome/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/navidrome/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/navidrome/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/navidrome/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/navidrome/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/navidrome/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/navidrome/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/navidrome/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/navidrome/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/navidrome/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/navidrome/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/navidrome/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/navidrome/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/navidrome/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/navidrome/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/navidrome/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/navidrome/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/navidrome/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/navidrome/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/navidrome/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/navidrome/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/navidrome/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/navidrome/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/navidrome/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/navidrome/templates/library/base_v2_1_8/validations.py b/ix-dev/community/navidrome/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/navidrome/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/navidrome/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/navidrome/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/navidrome/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/navidrome/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/navidrome/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/navidrome/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/navidrome/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/navidrome/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/navidrome/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/navidrome/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/navidrome/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/navidrome/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/navidrome/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/navidrome/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/navidrome/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/navidrome/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/navidrome/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/navidrome/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/navidrome/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/navidrome/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/navidrome/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/netbootxyz/app.yaml b/ix-dev/community/netbootxyz/app.yaml index 2671871303..9a374208e6 100644 --- a/ix-dev/community/netbootxyz/app.yaml +++ b/ix-dev/community/netbootxyz/app.yaml @@ -30,8 +30,8 @@ keywords: - netboot - netbootxyz - netboot.xyz -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -50,4 +50,4 @@ sources: - https://netboot.xyz title: Netboot.xyz train: community -version: 1.1.6 +version: 1.1.7 diff --git a/ix-dev/community/netbootxyz/templates/library/base_v2_1_8/container.py b/ix-dev/community/netbootxyz/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/netbootxyz/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/netbootxyz/templates/library/base_v2_1_8/ports.py b/ix-dev/community/netbootxyz/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/netbootxyz/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/netbootxyz/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/netbootxyz/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/netbootxyz/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/netbootxyz/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/netbootxyz/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/netbootxyz/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/netbootxyz/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/netbootxyz/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/netbootxyz/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/netbootxyz/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/netbootxyz/templates/library/base_v2_1_8/configs.py b/ix-dev/community/netbootxyz/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/netbootxyz/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/netbootxyz/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/netbootxyz/templates/library/base_v2_1_9/container.py b/ix-dev/community/netbootxyz/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/netbootxyz/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/netbootxyz/templates/library/base_v2_1_8/depends.py b/ix-dev/community/netbootxyz/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/netbootxyz/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/netbootxyz/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/netbootxyz/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/netbootxyz/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/netbootxyz/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/netbootxyz/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/netbootxyz/templates/library/base_v2_1_8/deps.py b/ix-dev/community/netbootxyz/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/netbootxyz/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/netbootxyz/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/netbootxyz/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/netbootxyz/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/netbootxyz/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/netbootxyz/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/netbootxyz/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/netbootxyz/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/netbootxyz/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/netbootxyz/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/netbootxyz/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/netbootxyz/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/netbootxyz/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/netbootxyz/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/netbootxyz/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/netbootxyz/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/netbootxyz/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/netbootxyz/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/netbootxyz/templates/library/base_v2_1_8/device.py b/ix-dev/community/netbootxyz/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/netbootxyz/templates/library/base_v2_1_8/device.py rename to ix-dev/community/netbootxyz/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/netbootxyz/templates/library/base_v2_1_8/devices.py b/ix-dev/community/netbootxyz/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/netbootxyz/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/netbootxyz/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/netbootxyz/templates/library/base_v2_1_8/dns.py b/ix-dev/community/netbootxyz/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/netbootxyz/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/netbootxyz/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/netbootxyz/templates/library/base_v2_1_8/environment.py b/ix-dev/community/netbootxyz/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/netbootxyz/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/netbootxyz/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/netbootxyz/templates/library/base_v2_1_8/error.py b/ix-dev/community/netbootxyz/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/netbootxyz/templates/library/base_v2_1_8/error.py rename to ix-dev/community/netbootxyz/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/netbootxyz/templates/library/base_v2_1_8/expose.py b/ix-dev/community/netbootxyz/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/netbootxyz/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/netbootxyz/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/netbootxyz/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/netbootxyz/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/netbootxyz/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/netbootxyz/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/netbootxyz/templates/library/base_v2_1_8/functions.py b/ix-dev/community/netbootxyz/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/netbootxyz/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/netbootxyz/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/netbootxyz/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/netbootxyz/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/netbootxyz/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/netbootxyz/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/netbootxyz/templates/library/base_v2_1_8/labels.py b/ix-dev/community/netbootxyz/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/netbootxyz/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/netbootxyz/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/netbootxyz/templates/library/base_v2_1_8/notes.py b/ix-dev/community/netbootxyz/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/netbootxyz/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/netbootxyz/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/netbootxyz/templates/library/base_v2_1_8/portal.py b/ix-dev/community/netbootxyz/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/netbootxyz/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/netbootxyz/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/netbootxyz/templates/library/base_v2_1_8/portals.py b/ix-dev/community/netbootxyz/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/netbootxyz/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/netbootxyz/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/netbootxyz/templates/library/base_v2_1_9/ports.py b/ix-dev/community/netbootxyz/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/netbootxyz/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/netbootxyz/templates/library/base_v2_1_8/render.py b/ix-dev/community/netbootxyz/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/netbootxyz/templates/library/base_v2_1_8/render.py rename to ix-dev/community/netbootxyz/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/netbootxyz/templates/library/base_v2_1_8/resources.py b/ix-dev/community/netbootxyz/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/netbootxyz/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/netbootxyz/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/netbootxyz/templates/library/base_v2_1_8/restart.py b/ix-dev/community/netbootxyz/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/netbootxyz/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/netbootxyz/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/netbootxyz/templates/library/base_v2_1_8/storage.py b/ix-dev/community/netbootxyz/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/netbootxyz/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/netbootxyz/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/netbootxyz/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/netbootxyz/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/netbootxyz/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/netbootxyz/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/netbootxyz/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/netbootxyz/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/netbootxyz/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/netbootxyz/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/netbootxyz/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/netbootxyz/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/netbootxyz/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/netbootxyz/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/netbootxyz/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/netbootxyz/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/netbootxyz/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/netbootxyz/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/netbootxyz/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/netbootxyz/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/netbootxyz/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/netbootxyz/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/netbootxyz/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/netbootxyz/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/netbootxyz/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/netbootxyz/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/netbootxyz/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/netbootxyz/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/netbootxyz/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/netbootxyz/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/netbootxyz/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/netbootxyz/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/netbootxyz/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/netbootxyz/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/netbootxyz/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/netbootxyz/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/netbootxyz/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/netbootxyz/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/netbootxyz/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/netbootxyz/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/netbootxyz/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/netbootxyz/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/netbootxyz/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/netbootxyz/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/netbootxyz/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/netbootxyz/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/netbootxyz/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/netbootxyz/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/netbootxyz/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/netbootxyz/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/netbootxyz/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/netbootxyz/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/netbootxyz/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/netbootxyz/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/netbootxyz/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/netbootxyz/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/netbootxyz/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/netbootxyz/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/netbootxyz/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/netbootxyz/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/netbootxyz/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/netbootxyz/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/netbootxyz/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/netbootxyz/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/netbootxyz/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/netbootxyz/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/netbootxyz/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/netbootxyz/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/netbootxyz/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/netbootxyz/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/netbootxyz/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/netbootxyz/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/netbootxyz/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/netbootxyz/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/netbootxyz/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/netbootxyz/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/netbootxyz/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/netbootxyz/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/netbootxyz/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/netbootxyz/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/netbootxyz/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/netbootxyz/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/netbootxyz/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/netbootxyz/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/netbootxyz/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/netbootxyz/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/netbootxyz/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/netbootxyz/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/netbootxyz/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/netbootxyz/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/netbootxyz/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/netbootxyz/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/netbootxyz/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/netbootxyz/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/netbootxyz/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/netbootxyz/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/netbootxyz/templates/library/base_v2_1_8/validations.py b/ix-dev/community/netbootxyz/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/netbootxyz/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/netbootxyz/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/netbootxyz/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/netbootxyz/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/netbootxyz/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/netbootxyz/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/netbootxyz/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/netbootxyz/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/netbootxyz/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/netbootxyz/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/netbootxyz/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/netbootxyz/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/netbootxyz/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/netbootxyz/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/netbootxyz/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/netbootxyz/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/netbootxyz/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/netbootxyz/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/netbootxyz/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/netbootxyz/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/netbootxyz/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/netbootxyz/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/nginx-proxy-manager/app.yaml b/ix-dev/community/nginx-proxy-manager/app.yaml index 471779eda5..d84333d26f 100644 --- a/ix-dev/community/nginx-proxy-manager/app.yaml +++ b/ix-dev/community/nginx-proxy-manager/app.yaml @@ -22,8 +22,8 @@ keywords: - reverse - nginx - proxy -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -44,4 +44,4 @@ sources: - https://hub.docker.com/r/jc21/nginx-proxy-manager title: Nginx Proxy Manager train: community -version: 1.1.6 +version: 1.1.7 diff --git a/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/container.py b/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/ports.py b/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/configs.py b/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/container.py b/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/depends.py b/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/deps.py b/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/device.py b/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/device.py rename to ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/devices.py b/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/dns.py b/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/environment.py b/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/error.py b/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/error.py rename to ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/expose.py b/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/functions.py b/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/labels.py b/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/notes.py b/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/portal.py b/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/portals.py b/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/ports.py b/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/render.py b/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/render.py rename to ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/resources.py b/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/restart.py b/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/storage.py b/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/validations.py b/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/nginx-proxy-manager/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/node-red/app.yaml b/ix-dev/community/node-red/app.yaml index 881eeeec7d..5c481916b3 100644 --- a/ix-dev/community/node-red/app.yaml +++ b/ix-dev/community/node-red/app.yaml @@ -9,8 +9,8 @@ host_mounts: [] icon: https://media.sys.truenas.net/apps/node-red/icons/icon.png keywords: - automation -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -29,4 +29,4 @@ sources: - https://github.com/node-red/node-red-docker title: Node-RED train: community -version: 1.1.8 +version: 1.1.9 diff --git a/ix-dev/community/node-red/templates/library/base_v2_1_8/container.py b/ix-dev/community/node-red/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/node-red/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/node-red/templates/library/base_v2_1_8/ports.py b/ix-dev/community/node-red/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/node-red/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/node-red/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/node-red/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/node-red/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/node-red/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/node-red/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/node-red/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/node-red/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/node-red/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/node-red/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/node-red/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/node-red/templates/library/base_v2_1_8/configs.py b/ix-dev/community/node-red/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/node-red/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/node-red/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/node-red/templates/library/base_v2_1_9/container.py b/ix-dev/community/node-red/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/node-red/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/node-red/templates/library/base_v2_1_8/depends.py b/ix-dev/community/node-red/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/node-red/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/node-red/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/node-red/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/node-red/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/node-red/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/node-red/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/node-red/templates/library/base_v2_1_8/deps.py b/ix-dev/community/node-red/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/node-red/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/node-red/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/node-red/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/node-red/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/node-red/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/node-red/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/node-red/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/node-red/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/node-red/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/node-red/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/node-red/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/node-red/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/node-red/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/node-red/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/node-red/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/node-red/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/node-red/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/node-red/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/node-red/templates/library/base_v2_1_8/device.py b/ix-dev/community/node-red/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/node-red/templates/library/base_v2_1_8/device.py rename to ix-dev/community/node-red/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/node-red/templates/library/base_v2_1_8/devices.py b/ix-dev/community/node-red/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/node-red/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/node-red/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/node-red/templates/library/base_v2_1_8/dns.py b/ix-dev/community/node-red/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/node-red/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/node-red/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/node-red/templates/library/base_v2_1_8/environment.py b/ix-dev/community/node-red/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/node-red/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/node-red/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/node-red/templates/library/base_v2_1_8/error.py b/ix-dev/community/node-red/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/node-red/templates/library/base_v2_1_8/error.py rename to ix-dev/community/node-red/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/node-red/templates/library/base_v2_1_8/expose.py b/ix-dev/community/node-red/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/node-red/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/node-red/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/node-red/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/node-red/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/node-red/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/node-red/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/node-red/templates/library/base_v2_1_8/functions.py b/ix-dev/community/node-red/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/node-red/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/node-red/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/node-red/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/node-red/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/node-red/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/node-red/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/node-red/templates/library/base_v2_1_8/labels.py b/ix-dev/community/node-red/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/node-red/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/node-red/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/node-red/templates/library/base_v2_1_8/notes.py b/ix-dev/community/node-red/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/node-red/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/node-red/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/node-red/templates/library/base_v2_1_8/portal.py b/ix-dev/community/node-red/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/node-red/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/node-red/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/node-red/templates/library/base_v2_1_8/portals.py b/ix-dev/community/node-red/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/node-red/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/node-red/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/node-red/templates/library/base_v2_1_9/ports.py b/ix-dev/community/node-red/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/node-red/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/node-red/templates/library/base_v2_1_8/render.py b/ix-dev/community/node-red/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/node-red/templates/library/base_v2_1_8/render.py rename to ix-dev/community/node-red/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/node-red/templates/library/base_v2_1_8/resources.py b/ix-dev/community/node-red/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/node-red/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/node-red/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/node-red/templates/library/base_v2_1_8/restart.py b/ix-dev/community/node-red/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/node-red/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/node-red/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/node-red/templates/library/base_v2_1_8/storage.py b/ix-dev/community/node-red/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/node-red/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/node-red/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/node-red/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/node-red/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/node-red/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/node-red/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/node-red/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/node-red/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/node-red/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/node-red/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/node-red/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/node-red/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/node-red/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/node-red/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/node-red/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/node-red/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/node-red/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/node-red/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/node-red/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/node-red/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/node-red/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/node-red/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/node-red/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/node-red/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/node-red/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/node-red/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/node-red/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/node-red/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/node-red/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/node-red/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/node-red/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/node-red/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/node-red/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/node-red/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/node-red/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/node-red/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/node-red/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/node-red/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/node-red/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/node-red/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/node-red/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/node-red/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/node-red/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/node-red/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/node-red/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/node-red/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/node-red/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/node-red/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/node-red/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/node-red/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/node-red/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/node-red/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/node-red/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/node-red/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/node-red/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/node-red/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/node-red/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/node-red/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/node-red/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/node-red/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/node-red/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/node-red/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/node-red/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/node-red/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/node-red/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/node-red/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/node-red/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/node-red/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/node-red/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/node-red/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/node-red/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/node-red/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/node-red/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/node-red/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/node-red/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/node-red/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/node-red/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/node-red/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/node-red/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/node-red/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/node-red/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/node-red/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/node-red/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/node-red/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/node-red/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/node-red/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/node-red/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/node-red/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/node-red/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/node-red/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/node-red/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/node-red/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/node-red/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/node-red/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/node-red/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/node-red/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/node-red/templates/library/base_v2_1_8/validations.py b/ix-dev/community/node-red/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/node-red/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/node-red/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/node-red/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/node-red/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/node-red/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/node-red/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/node-red/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/node-red/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/node-red/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/node-red/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/node-red/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/node-red/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/node-red/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/node-red/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/node-red/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/node-red/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/node-red/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/node-red/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/node-red/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/node-red/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/node-red/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/node-red/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/odoo/app.yaml b/ix-dev/community/odoo/app.yaml index cf34da2542..e63f87ea9d 100644 --- a/ix-dev/community/odoo/app.yaml +++ b/ix-dev/community/odoo/app.yaml @@ -9,8 +9,8 @@ icon: https://media.sys.truenas.net/apps/odoo/icons/icon.png keywords: - erp - odoo -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -35,4 +35,4 @@ sources: - https://github.com/odoo/odoo title: Odoo train: community -version: 1.2.4 +version: 1.2.5 diff --git a/ix-dev/community/odoo/templates/library/base_v2_1_8/container.py b/ix-dev/community/odoo/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/odoo/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/odoo/templates/library/base_v2_1_8/ports.py b/ix-dev/community/odoo/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/odoo/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/odoo/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/odoo/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/odoo/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/odoo/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/odoo/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/odoo/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/odoo/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/odoo/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/odoo/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/odoo/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/odoo/templates/library/base_v2_1_8/configs.py b/ix-dev/community/odoo/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/odoo/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/odoo/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/odoo/templates/library/base_v2_1_9/container.py b/ix-dev/community/odoo/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/odoo/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/odoo/templates/library/base_v2_1_8/depends.py b/ix-dev/community/odoo/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/odoo/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/odoo/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/odoo/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/odoo/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/odoo/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/odoo/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/odoo/templates/library/base_v2_1_8/deps.py b/ix-dev/community/odoo/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/odoo/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/odoo/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/odoo/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/odoo/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/odoo/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/odoo/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/odoo/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/odoo/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/odoo/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/odoo/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/odoo/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/odoo/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/odoo/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/odoo/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/odoo/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/odoo/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/odoo/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/odoo/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/odoo/templates/library/base_v2_1_8/device.py b/ix-dev/community/odoo/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/odoo/templates/library/base_v2_1_8/device.py rename to ix-dev/community/odoo/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/odoo/templates/library/base_v2_1_8/devices.py b/ix-dev/community/odoo/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/odoo/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/odoo/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/odoo/templates/library/base_v2_1_8/dns.py b/ix-dev/community/odoo/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/odoo/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/odoo/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/odoo/templates/library/base_v2_1_8/environment.py b/ix-dev/community/odoo/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/odoo/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/odoo/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/odoo/templates/library/base_v2_1_8/error.py b/ix-dev/community/odoo/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/odoo/templates/library/base_v2_1_8/error.py rename to ix-dev/community/odoo/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/odoo/templates/library/base_v2_1_8/expose.py b/ix-dev/community/odoo/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/odoo/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/odoo/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/odoo/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/odoo/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/odoo/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/odoo/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/odoo/templates/library/base_v2_1_8/functions.py b/ix-dev/community/odoo/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/odoo/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/odoo/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/odoo/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/odoo/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/odoo/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/odoo/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/odoo/templates/library/base_v2_1_8/labels.py b/ix-dev/community/odoo/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/odoo/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/odoo/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/odoo/templates/library/base_v2_1_8/notes.py b/ix-dev/community/odoo/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/odoo/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/odoo/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/odoo/templates/library/base_v2_1_8/portal.py b/ix-dev/community/odoo/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/odoo/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/odoo/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/odoo/templates/library/base_v2_1_8/portals.py b/ix-dev/community/odoo/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/odoo/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/odoo/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/odoo/templates/library/base_v2_1_9/ports.py b/ix-dev/community/odoo/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/odoo/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/odoo/templates/library/base_v2_1_8/render.py b/ix-dev/community/odoo/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/odoo/templates/library/base_v2_1_8/render.py rename to ix-dev/community/odoo/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/odoo/templates/library/base_v2_1_8/resources.py b/ix-dev/community/odoo/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/odoo/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/odoo/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/odoo/templates/library/base_v2_1_8/restart.py b/ix-dev/community/odoo/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/odoo/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/odoo/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/odoo/templates/library/base_v2_1_8/storage.py b/ix-dev/community/odoo/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/odoo/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/odoo/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/odoo/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/odoo/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/odoo/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/odoo/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/odoo/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/odoo/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/odoo/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/odoo/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/odoo/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/odoo/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/odoo/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/odoo/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/odoo/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/odoo/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/odoo/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/odoo/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/odoo/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/odoo/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/odoo/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/odoo/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/odoo/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/odoo/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/odoo/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/odoo/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/odoo/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/odoo/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/odoo/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/odoo/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/odoo/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/odoo/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/odoo/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/odoo/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/odoo/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/odoo/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/odoo/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/odoo/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/odoo/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/odoo/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/odoo/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/odoo/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/odoo/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/odoo/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/odoo/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/odoo/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/odoo/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/odoo/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/odoo/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/odoo/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/odoo/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/odoo/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/odoo/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/odoo/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/odoo/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/odoo/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/odoo/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/odoo/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/odoo/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/odoo/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/odoo/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/odoo/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/odoo/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/odoo/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/odoo/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/odoo/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/odoo/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/odoo/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/odoo/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/odoo/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/odoo/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/odoo/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/odoo/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/odoo/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/odoo/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/odoo/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/odoo/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/odoo/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/odoo/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/odoo/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/odoo/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/odoo/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/odoo/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/odoo/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/odoo/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/odoo/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/odoo/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/odoo/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/odoo/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/odoo/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/odoo/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/odoo/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/odoo/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/odoo/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/odoo/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/odoo/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/odoo/templates/library/base_v2_1_8/validations.py b/ix-dev/community/odoo/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/odoo/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/odoo/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/odoo/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/odoo/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/odoo/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/odoo/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/odoo/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/odoo/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/odoo/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/odoo/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/odoo/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/odoo/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/odoo/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/odoo/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/odoo/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/odoo/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/odoo/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/odoo/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/odoo/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/odoo/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/odoo/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/odoo/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/ollama/app.yaml b/ix-dev/community/ollama/app.yaml index 091aa14992..e981ed7889 100644 --- a/ix-dev/community/ollama/app.yaml +++ b/ix-dev/community/ollama/app.yaml @@ -10,8 +10,8 @@ icon: https://media.sys.truenas.net/apps/ollama/icons/icon.png keywords: - ai - llm -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -28,4 +28,4 @@ sources: - https://github.com/ollama/ollama title: Ollama train: community -version: 1.0.25 +version: 1.0.26 diff --git a/ix-dev/community/ollama/templates/library/base_v2_1_8/container.py b/ix-dev/community/ollama/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/ollama/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/ollama/templates/library/base_v2_1_8/ports.py b/ix-dev/community/ollama/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/ollama/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/ollama/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/ollama/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/ollama/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/ollama/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/ollama/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/ollama/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/ollama/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/ollama/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/ollama/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/ollama/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/ollama/templates/library/base_v2_1_8/configs.py b/ix-dev/community/ollama/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/ollama/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/ollama/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/ollama/templates/library/base_v2_1_9/container.py b/ix-dev/community/ollama/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/ollama/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/ollama/templates/library/base_v2_1_8/depends.py b/ix-dev/community/ollama/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/ollama/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/ollama/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/ollama/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/ollama/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/ollama/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/ollama/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/ollama/templates/library/base_v2_1_8/deps.py b/ix-dev/community/ollama/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/ollama/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/ollama/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/ollama/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/ollama/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/ollama/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/ollama/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/ollama/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/ollama/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/ollama/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/ollama/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/ollama/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/ollama/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/ollama/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/ollama/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/ollama/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/ollama/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/ollama/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/ollama/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/ollama/templates/library/base_v2_1_8/device.py b/ix-dev/community/ollama/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/ollama/templates/library/base_v2_1_8/device.py rename to ix-dev/community/ollama/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/ollama/templates/library/base_v2_1_8/devices.py b/ix-dev/community/ollama/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/ollama/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/ollama/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/ollama/templates/library/base_v2_1_8/dns.py b/ix-dev/community/ollama/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/ollama/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/ollama/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/ollama/templates/library/base_v2_1_8/environment.py b/ix-dev/community/ollama/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/ollama/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/ollama/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/ollama/templates/library/base_v2_1_8/error.py b/ix-dev/community/ollama/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/ollama/templates/library/base_v2_1_8/error.py rename to ix-dev/community/ollama/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/ollama/templates/library/base_v2_1_8/expose.py b/ix-dev/community/ollama/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/ollama/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/ollama/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/ollama/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/ollama/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/ollama/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/ollama/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/ollama/templates/library/base_v2_1_8/functions.py b/ix-dev/community/ollama/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/ollama/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/ollama/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/ollama/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/ollama/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/ollama/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/ollama/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/ollama/templates/library/base_v2_1_8/labels.py b/ix-dev/community/ollama/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/ollama/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/ollama/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/ollama/templates/library/base_v2_1_8/notes.py b/ix-dev/community/ollama/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/ollama/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/ollama/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/ollama/templates/library/base_v2_1_8/portal.py b/ix-dev/community/ollama/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/ollama/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/ollama/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/ollama/templates/library/base_v2_1_8/portals.py b/ix-dev/community/ollama/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/ollama/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/ollama/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/ollama/templates/library/base_v2_1_9/ports.py b/ix-dev/community/ollama/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/ollama/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/ollama/templates/library/base_v2_1_8/render.py b/ix-dev/community/ollama/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/ollama/templates/library/base_v2_1_8/render.py rename to ix-dev/community/ollama/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/ollama/templates/library/base_v2_1_8/resources.py b/ix-dev/community/ollama/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/ollama/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/ollama/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/ollama/templates/library/base_v2_1_8/restart.py b/ix-dev/community/ollama/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/ollama/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/ollama/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/ollama/templates/library/base_v2_1_8/storage.py b/ix-dev/community/ollama/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/ollama/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/ollama/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/ollama/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/ollama/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/ollama/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/ollama/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/ollama/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/ollama/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/ollama/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/ollama/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/ollama/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/ollama/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/ollama/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/ollama/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/ollama/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/ollama/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/ollama/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/ollama/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/ollama/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/ollama/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/ollama/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/ollama/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/ollama/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/ollama/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/ollama/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/ollama/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/ollama/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/ollama/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/ollama/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/ollama/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/ollama/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/ollama/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/ollama/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/ollama/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/ollama/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/ollama/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/ollama/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/ollama/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/ollama/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/ollama/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/ollama/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/ollama/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/ollama/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/ollama/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/ollama/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/ollama/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/ollama/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/ollama/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/ollama/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/ollama/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/ollama/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/ollama/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/ollama/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/ollama/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/ollama/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/ollama/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/ollama/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/ollama/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/ollama/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/ollama/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/ollama/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/ollama/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/ollama/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/ollama/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/ollama/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/ollama/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/ollama/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/ollama/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/ollama/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/ollama/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/ollama/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/ollama/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/ollama/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/ollama/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/ollama/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/ollama/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/ollama/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/ollama/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/ollama/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/ollama/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/ollama/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/ollama/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/ollama/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/ollama/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/ollama/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/ollama/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/ollama/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/ollama/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/ollama/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/ollama/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/ollama/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/ollama/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/ollama/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/ollama/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/ollama/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/ollama/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/ollama/templates/library/base_v2_1_8/validations.py b/ix-dev/community/ollama/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/ollama/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/ollama/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/ollama/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/ollama/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/ollama/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/ollama/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/ollama/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/ollama/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/ollama/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/ollama/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/ollama/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/ollama/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/ollama/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/ollama/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/ollama/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/ollama/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/ollama/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/ollama/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/ollama/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/ollama/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/ollama/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/ollama/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/omada-controller/app.yaml b/ix-dev/community/omada-controller/app.yaml index e34ba6f66c..5b1cc60280 100644 --- a/ix-dev/community/omada-controller/app.yaml +++ b/ix-dev/community/omada-controller/app.yaml @@ -23,8 +23,8 @@ keywords: - controller - omada - tp-link -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -42,4 +42,4 @@ sources: - https://hub.docker.com/r/mbentley/omada-controller title: Omada Controller train: community -version: 1.2.8 +version: 1.2.9 diff --git a/ix-dev/community/omada-controller/templates/library/base_v2_1_8/container.py b/ix-dev/community/omada-controller/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/omada-controller/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/omada-controller/templates/library/base_v2_1_8/ports.py b/ix-dev/community/omada-controller/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/omada-controller/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/omada-controller/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/omada-controller/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/omada-controller/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/omada-controller/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/omada-controller/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/omada-controller/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/omada-controller/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/omada-controller/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/omada-controller/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/omada-controller/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/omada-controller/templates/library/base_v2_1_8/configs.py b/ix-dev/community/omada-controller/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/omada-controller/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/omada-controller/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/omada-controller/templates/library/base_v2_1_9/container.py b/ix-dev/community/omada-controller/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/omada-controller/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/omada-controller/templates/library/base_v2_1_8/depends.py b/ix-dev/community/omada-controller/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/omada-controller/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/omada-controller/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/omada-controller/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/omada-controller/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/omada-controller/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/omada-controller/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/omada-controller/templates/library/base_v2_1_8/deps.py b/ix-dev/community/omada-controller/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/omada-controller/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/omada-controller/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/omada-controller/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/omada-controller/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/omada-controller/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/omada-controller/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/omada-controller/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/omada-controller/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/omada-controller/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/omada-controller/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/omada-controller/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/omada-controller/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/omada-controller/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/omada-controller/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/omada-controller/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/omada-controller/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/omada-controller/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/omada-controller/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/omada-controller/templates/library/base_v2_1_8/device.py b/ix-dev/community/omada-controller/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/omada-controller/templates/library/base_v2_1_8/device.py rename to ix-dev/community/omada-controller/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/omada-controller/templates/library/base_v2_1_8/devices.py b/ix-dev/community/omada-controller/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/omada-controller/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/omada-controller/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/omada-controller/templates/library/base_v2_1_8/dns.py b/ix-dev/community/omada-controller/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/omada-controller/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/omada-controller/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/omada-controller/templates/library/base_v2_1_8/environment.py b/ix-dev/community/omada-controller/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/omada-controller/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/omada-controller/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/omada-controller/templates/library/base_v2_1_8/error.py b/ix-dev/community/omada-controller/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/omada-controller/templates/library/base_v2_1_8/error.py rename to ix-dev/community/omada-controller/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/omada-controller/templates/library/base_v2_1_8/expose.py b/ix-dev/community/omada-controller/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/omada-controller/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/omada-controller/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/omada-controller/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/omada-controller/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/omada-controller/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/omada-controller/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/omada-controller/templates/library/base_v2_1_8/functions.py b/ix-dev/community/omada-controller/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/omada-controller/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/omada-controller/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/omada-controller/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/omada-controller/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/omada-controller/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/omada-controller/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/omada-controller/templates/library/base_v2_1_8/labels.py b/ix-dev/community/omada-controller/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/omada-controller/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/omada-controller/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/omada-controller/templates/library/base_v2_1_8/notes.py b/ix-dev/community/omada-controller/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/omada-controller/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/omada-controller/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/omada-controller/templates/library/base_v2_1_8/portal.py b/ix-dev/community/omada-controller/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/omada-controller/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/omada-controller/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/omada-controller/templates/library/base_v2_1_8/portals.py b/ix-dev/community/omada-controller/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/omada-controller/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/omada-controller/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/omada-controller/templates/library/base_v2_1_9/ports.py b/ix-dev/community/omada-controller/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/omada-controller/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/omada-controller/templates/library/base_v2_1_8/render.py b/ix-dev/community/omada-controller/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/omada-controller/templates/library/base_v2_1_8/render.py rename to ix-dev/community/omada-controller/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/omada-controller/templates/library/base_v2_1_8/resources.py b/ix-dev/community/omada-controller/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/omada-controller/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/omada-controller/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/omada-controller/templates/library/base_v2_1_8/restart.py b/ix-dev/community/omada-controller/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/omada-controller/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/omada-controller/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/omada-controller/templates/library/base_v2_1_8/storage.py b/ix-dev/community/omada-controller/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/omada-controller/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/omada-controller/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/omada-controller/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/omada-controller/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/omada-controller/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/omada-controller/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/omada-controller/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/omada-controller/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/omada-controller/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/omada-controller/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/omada-controller/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/omada-controller/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/omada-controller/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/omada-controller/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/omada-controller/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/omada-controller/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/omada-controller/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/omada-controller/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/omada-controller/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/omada-controller/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/omada-controller/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/omada-controller/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/omada-controller/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/omada-controller/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/omada-controller/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/omada-controller/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/omada-controller/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/omada-controller/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/omada-controller/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/omada-controller/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/omada-controller/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/omada-controller/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/omada-controller/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/omada-controller/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/omada-controller/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/omada-controller/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/omada-controller/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/omada-controller/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/omada-controller/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/omada-controller/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/omada-controller/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/omada-controller/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/omada-controller/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/omada-controller/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/omada-controller/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/omada-controller/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/omada-controller/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/omada-controller/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/omada-controller/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/omada-controller/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/omada-controller/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/omada-controller/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/omada-controller/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/omada-controller/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/omada-controller/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/omada-controller/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/omada-controller/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/omada-controller/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/omada-controller/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/omada-controller/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/omada-controller/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/omada-controller/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/omada-controller/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/omada-controller/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/omada-controller/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/omada-controller/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/omada-controller/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/omada-controller/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/omada-controller/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/omada-controller/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/omada-controller/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/omada-controller/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/omada-controller/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/omada-controller/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/omada-controller/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/omada-controller/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/omada-controller/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/omada-controller/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/omada-controller/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/omada-controller/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/omada-controller/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/omada-controller/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/omada-controller/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/omada-controller/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/omada-controller/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/omada-controller/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/omada-controller/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/omada-controller/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/omada-controller/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/omada-controller/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/omada-controller/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/omada-controller/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/omada-controller/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/omada-controller/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/omada-controller/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/omada-controller/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/omada-controller/templates/library/base_v2_1_8/validations.py b/ix-dev/community/omada-controller/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/omada-controller/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/omada-controller/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/omada-controller/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/omada-controller/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/omada-controller/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/omada-controller/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/omada-controller/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/omada-controller/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/omada-controller/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/omada-controller/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/omada-controller/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/omada-controller/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/omada-controller/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/omada-controller/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/omada-controller/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/omada-controller/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/omada-controller/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/omada-controller/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/omada-controller/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/omada-controller/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/omada-controller/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/omada-controller/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/open-webui/app.yaml b/ix-dev/community/open-webui/app.yaml index a3662abf92..b39af18272 100644 --- a/ix-dev/community/open-webui/app.yaml +++ b/ix-dev/community/open-webui/app.yaml @@ -13,8 +13,8 @@ keywords: - llm - webui - open-webui -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -32,4 +32,4 @@ sources: - https://github.com/open-webui/open-webui title: Open WebUI train: community -version: 1.0.22 +version: 1.0.23 diff --git a/ix-dev/community/open-webui/templates/library/base_v2_1_8/container.py b/ix-dev/community/open-webui/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/open-webui/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/open-webui/templates/library/base_v2_1_8/ports.py b/ix-dev/community/open-webui/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/open-webui/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/open-webui/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/open-webui/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/open-webui/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/open-webui/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/open-webui/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/open-webui/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/open-webui/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/open-webui/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/open-webui/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/open-webui/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/open-webui/templates/library/base_v2_1_8/configs.py b/ix-dev/community/open-webui/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/open-webui/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/open-webui/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/open-webui/templates/library/base_v2_1_9/container.py b/ix-dev/community/open-webui/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/open-webui/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/open-webui/templates/library/base_v2_1_8/depends.py b/ix-dev/community/open-webui/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/open-webui/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/open-webui/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/open-webui/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/open-webui/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/open-webui/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/open-webui/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/open-webui/templates/library/base_v2_1_8/deps.py b/ix-dev/community/open-webui/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/open-webui/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/open-webui/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/open-webui/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/open-webui/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/open-webui/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/open-webui/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/open-webui/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/open-webui/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/open-webui/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/open-webui/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/open-webui/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/open-webui/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/open-webui/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/open-webui/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/open-webui/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/open-webui/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/open-webui/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/open-webui/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/open-webui/templates/library/base_v2_1_8/device.py b/ix-dev/community/open-webui/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/open-webui/templates/library/base_v2_1_8/device.py rename to ix-dev/community/open-webui/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/open-webui/templates/library/base_v2_1_8/devices.py b/ix-dev/community/open-webui/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/open-webui/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/open-webui/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/open-webui/templates/library/base_v2_1_8/dns.py b/ix-dev/community/open-webui/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/open-webui/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/open-webui/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/open-webui/templates/library/base_v2_1_8/environment.py b/ix-dev/community/open-webui/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/open-webui/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/open-webui/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/open-webui/templates/library/base_v2_1_8/error.py b/ix-dev/community/open-webui/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/open-webui/templates/library/base_v2_1_8/error.py rename to ix-dev/community/open-webui/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/open-webui/templates/library/base_v2_1_8/expose.py b/ix-dev/community/open-webui/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/open-webui/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/open-webui/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/open-webui/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/open-webui/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/open-webui/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/open-webui/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/open-webui/templates/library/base_v2_1_8/functions.py b/ix-dev/community/open-webui/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/open-webui/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/open-webui/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/open-webui/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/open-webui/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/open-webui/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/open-webui/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/open-webui/templates/library/base_v2_1_8/labels.py b/ix-dev/community/open-webui/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/open-webui/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/open-webui/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/open-webui/templates/library/base_v2_1_8/notes.py b/ix-dev/community/open-webui/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/open-webui/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/open-webui/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/open-webui/templates/library/base_v2_1_8/portal.py b/ix-dev/community/open-webui/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/open-webui/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/open-webui/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/open-webui/templates/library/base_v2_1_8/portals.py b/ix-dev/community/open-webui/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/open-webui/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/open-webui/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/open-webui/templates/library/base_v2_1_9/ports.py b/ix-dev/community/open-webui/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/open-webui/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/open-webui/templates/library/base_v2_1_8/render.py b/ix-dev/community/open-webui/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/open-webui/templates/library/base_v2_1_8/render.py rename to ix-dev/community/open-webui/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/open-webui/templates/library/base_v2_1_8/resources.py b/ix-dev/community/open-webui/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/open-webui/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/open-webui/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/open-webui/templates/library/base_v2_1_8/restart.py b/ix-dev/community/open-webui/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/open-webui/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/open-webui/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/open-webui/templates/library/base_v2_1_8/storage.py b/ix-dev/community/open-webui/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/open-webui/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/open-webui/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/open-webui/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/open-webui/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/open-webui/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/open-webui/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/open-webui/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/open-webui/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/open-webui/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/open-webui/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/open-webui/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/open-webui/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/open-webui/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/open-webui/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/open-webui/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/open-webui/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/open-webui/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/open-webui/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/open-webui/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/open-webui/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/open-webui/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/open-webui/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/open-webui/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/open-webui/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/open-webui/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/open-webui/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/open-webui/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/open-webui/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/open-webui/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/open-webui/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/open-webui/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/open-webui/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/open-webui/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/open-webui/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/open-webui/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/open-webui/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/open-webui/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/open-webui/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/open-webui/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/open-webui/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/open-webui/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/open-webui/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/open-webui/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/open-webui/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/open-webui/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/open-webui/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/open-webui/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/open-webui/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/open-webui/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/open-webui/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/open-webui/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/open-webui/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/open-webui/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/open-webui/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/open-webui/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/open-webui/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/open-webui/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/open-webui/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/open-webui/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/open-webui/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/open-webui/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/open-webui/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/open-webui/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/open-webui/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/open-webui/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/open-webui/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/open-webui/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/open-webui/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/open-webui/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/open-webui/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/open-webui/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/open-webui/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/open-webui/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/open-webui/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/open-webui/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/open-webui/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/open-webui/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/open-webui/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/open-webui/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/open-webui/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/open-webui/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/open-webui/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/open-webui/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/open-webui/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/open-webui/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/open-webui/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/open-webui/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/open-webui/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/open-webui/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/open-webui/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/open-webui/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/open-webui/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/open-webui/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/open-webui/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/open-webui/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/open-webui/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/open-webui/templates/library/base_v2_1_8/validations.py b/ix-dev/community/open-webui/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/open-webui/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/open-webui/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/open-webui/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/open-webui/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/open-webui/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/open-webui/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/open-webui/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/open-webui/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/open-webui/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/open-webui/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/open-webui/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/open-webui/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/open-webui/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/open-webui/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/open-webui/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/open-webui/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/open-webui/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/open-webui/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/open-webui/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/open-webui/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/open-webui/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/open-webui/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/organizr/app.yaml b/ix-dev/community/organizr/app.yaml index 4d34e628a7..f7cdf6f217 100644 --- a/ix-dev/community/organizr/app.yaml +++ b/ix-dev/community/organizr/app.yaml @@ -19,8 +19,8 @@ icon: https://media.sys.truenas.net/apps/organizr/icons/icon.png keywords: - dashboard - organizr -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -40,4 +40,4 @@ sources: - https://github.com/causefx/Organizr title: Organizr train: community -version: 1.1.5 +version: 1.1.6 diff --git a/ix-dev/community/organizr/templates/library/base_v2_1_8/container.py b/ix-dev/community/organizr/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/organizr/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/organizr/templates/library/base_v2_1_8/ports.py b/ix-dev/community/organizr/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/organizr/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/organizr/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/organizr/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/organizr/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/organizr/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/organizr/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/organizr/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/organizr/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/organizr/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/organizr/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/organizr/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/organizr/templates/library/base_v2_1_8/configs.py b/ix-dev/community/organizr/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/organizr/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/organizr/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/organizr/templates/library/base_v2_1_9/container.py b/ix-dev/community/organizr/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/organizr/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/organizr/templates/library/base_v2_1_8/depends.py b/ix-dev/community/organizr/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/organizr/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/organizr/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/organizr/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/organizr/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/organizr/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/organizr/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/organizr/templates/library/base_v2_1_8/deps.py b/ix-dev/community/organizr/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/organizr/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/organizr/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/organizr/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/organizr/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/organizr/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/organizr/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/organizr/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/organizr/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/organizr/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/organizr/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/organizr/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/organizr/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/organizr/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/organizr/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/organizr/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/organizr/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/organizr/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/organizr/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/organizr/templates/library/base_v2_1_8/device.py b/ix-dev/community/organizr/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/organizr/templates/library/base_v2_1_8/device.py rename to ix-dev/community/organizr/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/organizr/templates/library/base_v2_1_8/devices.py b/ix-dev/community/organizr/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/organizr/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/organizr/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/organizr/templates/library/base_v2_1_8/dns.py b/ix-dev/community/organizr/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/organizr/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/organizr/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/organizr/templates/library/base_v2_1_8/environment.py b/ix-dev/community/organizr/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/organizr/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/organizr/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/organizr/templates/library/base_v2_1_8/error.py b/ix-dev/community/organizr/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/organizr/templates/library/base_v2_1_8/error.py rename to ix-dev/community/organizr/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/organizr/templates/library/base_v2_1_8/expose.py b/ix-dev/community/organizr/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/organizr/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/organizr/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/organizr/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/organizr/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/organizr/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/organizr/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/organizr/templates/library/base_v2_1_8/functions.py b/ix-dev/community/organizr/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/organizr/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/organizr/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/organizr/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/organizr/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/organizr/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/organizr/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/organizr/templates/library/base_v2_1_8/labels.py b/ix-dev/community/organizr/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/organizr/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/organizr/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/organizr/templates/library/base_v2_1_8/notes.py b/ix-dev/community/organizr/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/organizr/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/organizr/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/organizr/templates/library/base_v2_1_8/portal.py b/ix-dev/community/organizr/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/organizr/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/organizr/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/organizr/templates/library/base_v2_1_8/portals.py b/ix-dev/community/organizr/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/organizr/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/organizr/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/organizr/templates/library/base_v2_1_9/ports.py b/ix-dev/community/organizr/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/organizr/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/organizr/templates/library/base_v2_1_8/render.py b/ix-dev/community/organizr/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/organizr/templates/library/base_v2_1_8/render.py rename to ix-dev/community/organizr/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/organizr/templates/library/base_v2_1_8/resources.py b/ix-dev/community/organizr/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/organizr/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/organizr/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/organizr/templates/library/base_v2_1_8/restart.py b/ix-dev/community/organizr/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/organizr/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/organizr/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/organizr/templates/library/base_v2_1_8/storage.py b/ix-dev/community/organizr/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/organizr/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/organizr/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/organizr/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/organizr/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/organizr/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/organizr/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/organizr/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/organizr/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/organizr/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/organizr/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/organizr/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/organizr/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/organizr/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/organizr/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/organizr/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/organizr/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/organizr/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/organizr/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/organizr/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/organizr/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/organizr/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/organizr/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/organizr/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/organizr/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/organizr/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/organizr/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/organizr/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/organizr/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/organizr/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/organizr/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/organizr/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/organizr/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/organizr/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/organizr/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/organizr/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/organizr/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/organizr/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/organizr/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/organizr/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/organizr/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/organizr/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/organizr/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/organizr/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/organizr/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/organizr/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/organizr/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/organizr/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/organizr/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/organizr/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/organizr/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/organizr/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/organizr/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/organizr/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/organizr/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/organizr/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/organizr/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/organizr/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/organizr/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/organizr/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/organizr/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/organizr/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/organizr/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/organizr/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/organizr/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/organizr/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/organizr/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/organizr/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/organizr/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/organizr/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/organizr/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/organizr/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/organizr/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/organizr/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/organizr/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/organizr/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/organizr/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/organizr/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/organizr/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/organizr/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/organizr/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/organizr/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/organizr/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/organizr/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/organizr/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/organizr/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/organizr/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/organizr/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/organizr/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/organizr/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/organizr/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/organizr/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/organizr/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/organizr/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/organizr/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/organizr/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/organizr/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/organizr/templates/library/base_v2_1_8/validations.py b/ix-dev/community/organizr/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/organizr/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/organizr/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/organizr/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/organizr/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/organizr/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/organizr/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/organizr/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/organizr/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/organizr/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/organizr/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/organizr/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/organizr/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/organizr/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/organizr/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/organizr/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/organizr/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/organizr/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/organizr/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/organizr/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/organizr/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/organizr/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/organizr/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/overseerr/app.yaml b/ix-dev/community/overseerr/app.yaml index 7ebf99f4cb..d5b543dc13 100644 --- a/ix-dev/community/overseerr/app.yaml +++ b/ix-dev/community/overseerr/app.yaml @@ -9,8 +9,8 @@ host_mounts: [] icon: https://media.sys.truenas.net/apps/overseerr/icons/icon.svg keywords: - media -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -28,4 +28,4 @@ sources: - https://github.com/sct/overseerr title: Overseerr train: community -version: 1.1.5 +version: 1.1.6 diff --git a/ix-dev/community/overseerr/templates/library/base_v2_1_8/container.py b/ix-dev/community/overseerr/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/overseerr/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/overseerr/templates/library/base_v2_1_8/ports.py b/ix-dev/community/overseerr/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/overseerr/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/overseerr/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/overseerr/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/overseerr/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/overseerr/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/overseerr/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/overseerr/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/overseerr/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/overseerr/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/overseerr/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/overseerr/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/overseerr/templates/library/base_v2_1_8/configs.py b/ix-dev/community/overseerr/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/overseerr/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/overseerr/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/overseerr/templates/library/base_v2_1_9/container.py b/ix-dev/community/overseerr/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/overseerr/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/overseerr/templates/library/base_v2_1_8/depends.py b/ix-dev/community/overseerr/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/overseerr/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/overseerr/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/overseerr/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/overseerr/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/overseerr/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/overseerr/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/overseerr/templates/library/base_v2_1_8/deps.py b/ix-dev/community/overseerr/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/overseerr/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/overseerr/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/overseerr/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/overseerr/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/overseerr/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/overseerr/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/overseerr/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/overseerr/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/overseerr/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/overseerr/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/overseerr/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/overseerr/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/overseerr/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/overseerr/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/overseerr/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/overseerr/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/overseerr/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/overseerr/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/overseerr/templates/library/base_v2_1_8/device.py b/ix-dev/community/overseerr/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/overseerr/templates/library/base_v2_1_8/device.py rename to ix-dev/community/overseerr/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/overseerr/templates/library/base_v2_1_8/devices.py b/ix-dev/community/overseerr/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/overseerr/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/overseerr/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/overseerr/templates/library/base_v2_1_8/dns.py b/ix-dev/community/overseerr/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/overseerr/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/overseerr/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/overseerr/templates/library/base_v2_1_8/environment.py b/ix-dev/community/overseerr/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/overseerr/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/overseerr/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/overseerr/templates/library/base_v2_1_8/error.py b/ix-dev/community/overseerr/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/overseerr/templates/library/base_v2_1_8/error.py rename to ix-dev/community/overseerr/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/overseerr/templates/library/base_v2_1_8/expose.py b/ix-dev/community/overseerr/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/overseerr/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/overseerr/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/overseerr/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/overseerr/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/overseerr/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/overseerr/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/overseerr/templates/library/base_v2_1_8/functions.py b/ix-dev/community/overseerr/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/overseerr/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/overseerr/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/overseerr/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/overseerr/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/overseerr/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/overseerr/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/overseerr/templates/library/base_v2_1_8/labels.py b/ix-dev/community/overseerr/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/overseerr/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/overseerr/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/overseerr/templates/library/base_v2_1_8/notes.py b/ix-dev/community/overseerr/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/overseerr/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/overseerr/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/overseerr/templates/library/base_v2_1_8/portal.py b/ix-dev/community/overseerr/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/overseerr/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/overseerr/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/overseerr/templates/library/base_v2_1_8/portals.py b/ix-dev/community/overseerr/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/overseerr/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/overseerr/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/overseerr/templates/library/base_v2_1_9/ports.py b/ix-dev/community/overseerr/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/overseerr/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/overseerr/templates/library/base_v2_1_8/render.py b/ix-dev/community/overseerr/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/overseerr/templates/library/base_v2_1_8/render.py rename to ix-dev/community/overseerr/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/overseerr/templates/library/base_v2_1_8/resources.py b/ix-dev/community/overseerr/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/overseerr/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/overseerr/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/overseerr/templates/library/base_v2_1_8/restart.py b/ix-dev/community/overseerr/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/overseerr/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/overseerr/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/overseerr/templates/library/base_v2_1_8/storage.py b/ix-dev/community/overseerr/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/overseerr/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/overseerr/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/overseerr/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/overseerr/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/overseerr/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/overseerr/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/overseerr/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/overseerr/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/overseerr/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/overseerr/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/overseerr/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/overseerr/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/overseerr/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/overseerr/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/overseerr/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/overseerr/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/overseerr/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/overseerr/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/overseerr/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/overseerr/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/overseerr/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/overseerr/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/overseerr/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/overseerr/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/overseerr/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/overseerr/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/overseerr/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/overseerr/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/overseerr/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/overseerr/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/overseerr/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/overseerr/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/overseerr/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/overseerr/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/overseerr/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/overseerr/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/overseerr/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/overseerr/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/overseerr/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/overseerr/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/overseerr/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/overseerr/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/overseerr/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/overseerr/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/overseerr/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/overseerr/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/overseerr/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/overseerr/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/overseerr/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/overseerr/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/overseerr/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/overseerr/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/overseerr/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/overseerr/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/overseerr/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/overseerr/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/overseerr/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/overseerr/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/overseerr/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/overseerr/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/overseerr/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/overseerr/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/overseerr/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/overseerr/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/overseerr/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/overseerr/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/overseerr/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/overseerr/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/overseerr/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/overseerr/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/overseerr/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/overseerr/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/overseerr/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/overseerr/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/overseerr/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/overseerr/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/overseerr/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/overseerr/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/overseerr/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/overseerr/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/overseerr/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/overseerr/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/overseerr/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/overseerr/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/overseerr/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/overseerr/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/overseerr/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/overseerr/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/overseerr/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/overseerr/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/overseerr/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/overseerr/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/overseerr/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/overseerr/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/overseerr/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/overseerr/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/overseerr/templates/library/base_v2_1_8/validations.py b/ix-dev/community/overseerr/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/overseerr/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/overseerr/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/overseerr/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/overseerr/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/overseerr/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/overseerr/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/overseerr/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/overseerr/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/overseerr/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/overseerr/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/overseerr/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/overseerr/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/overseerr/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/overseerr/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/overseerr/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/overseerr/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/overseerr/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/overseerr/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/overseerr/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/overseerr/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/overseerr/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/overseerr/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/palworld/app.yaml b/ix-dev/community/palworld/app.yaml index 330c1f8223..5cedd9f8fb 100644 --- a/ix-dev/community/palworld/app.yaml +++ b/ix-dev/community/palworld/app.yaml @@ -28,8 +28,8 @@ icon: https://media.sys.truenas.net/apps/palworld/icons/icon.webp keywords: - game - palworld -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -47,4 +47,4 @@ sources: - https://github.com/ich777/docker-steamcmd-server/tree/palworld title: Palworld train: community -version: 1.1.7 +version: 1.1.8 diff --git a/ix-dev/community/palworld/templates/library/base_v2_1_8/container.py b/ix-dev/community/palworld/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/palworld/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/palworld/templates/library/base_v2_1_8/ports.py b/ix-dev/community/palworld/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/palworld/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/palworld/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/palworld/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/palworld/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/palworld/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/palworld/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/palworld/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/palworld/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/palworld/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/palworld/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/palworld/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/palworld/templates/library/base_v2_1_8/configs.py b/ix-dev/community/palworld/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/palworld/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/palworld/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/palworld/templates/library/base_v2_1_9/container.py b/ix-dev/community/palworld/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/palworld/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/palworld/templates/library/base_v2_1_8/depends.py b/ix-dev/community/palworld/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/palworld/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/palworld/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/palworld/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/palworld/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/palworld/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/palworld/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/palworld/templates/library/base_v2_1_8/deps.py b/ix-dev/community/palworld/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/palworld/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/palworld/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/palworld/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/palworld/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/palworld/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/palworld/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/palworld/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/palworld/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/palworld/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/palworld/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/palworld/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/palworld/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/palworld/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/palworld/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/palworld/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/palworld/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/palworld/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/palworld/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/palworld/templates/library/base_v2_1_8/device.py b/ix-dev/community/palworld/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/palworld/templates/library/base_v2_1_8/device.py rename to ix-dev/community/palworld/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/palworld/templates/library/base_v2_1_8/devices.py b/ix-dev/community/palworld/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/palworld/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/palworld/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/palworld/templates/library/base_v2_1_8/dns.py b/ix-dev/community/palworld/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/palworld/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/palworld/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/palworld/templates/library/base_v2_1_8/environment.py b/ix-dev/community/palworld/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/palworld/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/palworld/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/palworld/templates/library/base_v2_1_8/error.py b/ix-dev/community/palworld/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/palworld/templates/library/base_v2_1_8/error.py rename to ix-dev/community/palworld/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/palworld/templates/library/base_v2_1_8/expose.py b/ix-dev/community/palworld/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/palworld/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/palworld/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/palworld/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/palworld/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/palworld/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/palworld/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/palworld/templates/library/base_v2_1_8/functions.py b/ix-dev/community/palworld/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/palworld/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/palworld/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/palworld/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/palworld/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/palworld/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/palworld/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/palworld/templates/library/base_v2_1_8/labels.py b/ix-dev/community/palworld/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/palworld/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/palworld/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/palworld/templates/library/base_v2_1_8/notes.py b/ix-dev/community/palworld/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/palworld/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/palworld/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/palworld/templates/library/base_v2_1_8/portal.py b/ix-dev/community/palworld/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/palworld/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/palworld/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/palworld/templates/library/base_v2_1_8/portals.py b/ix-dev/community/palworld/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/palworld/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/palworld/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/palworld/templates/library/base_v2_1_9/ports.py b/ix-dev/community/palworld/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/palworld/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/palworld/templates/library/base_v2_1_8/render.py b/ix-dev/community/palworld/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/palworld/templates/library/base_v2_1_8/render.py rename to ix-dev/community/palworld/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/palworld/templates/library/base_v2_1_8/resources.py b/ix-dev/community/palworld/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/palworld/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/palworld/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/palworld/templates/library/base_v2_1_8/restart.py b/ix-dev/community/palworld/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/palworld/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/palworld/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/palworld/templates/library/base_v2_1_8/storage.py b/ix-dev/community/palworld/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/palworld/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/palworld/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/palworld/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/palworld/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/palworld/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/palworld/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/palworld/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/palworld/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/palworld/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/palworld/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/palworld/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/palworld/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/palworld/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/palworld/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/palworld/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/palworld/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/palworld/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/palworld/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/palworld/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/palworld/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/palworld/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/palworld/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/palworld/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/palworld/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/palworld/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/palworld/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/palworld/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/palworld/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/palworld/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/palworld/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/palworld/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/palworld/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/palworld/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/palworld/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/palworld/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/palworld/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/palworld/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/palworld/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/palworld/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/palworld/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/palworld/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/palworld/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/palworld/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/palworld/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/palworld/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/palworld/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/palworld/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/palworld/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/palworld/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/palworld/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/palworld/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/palworld/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/palworld/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/palworld/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/palworld/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/palworld/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/palworld/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/palworld/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/palworld/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/palworld/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/palworld/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/palworld/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/palworld/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/palworld/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/palworld/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/palworld/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/palworld/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/palworld/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/palworld/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/palworld/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/palworld/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/palworld/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/palworld/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/palworld/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/palworld/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/palworld/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/palworld/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/palworld/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/palworld/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/palworld/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/palworld/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/palworld/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/palworld/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/palworld/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/palworld/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/palworld/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/palworld/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/palworld/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/palworld/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/palworld/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/palworld/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/palworld/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/palworld/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/palworld/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/palworld/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/palworld/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/palworld/templates/library/base_v2_1_8/validations.py b/ix-dev/community/palworld/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/palworld/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/palworld/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/palworld/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/palworld/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/palworld/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/palworld/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/palworld/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/palworld/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/palworld/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/palworld/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/palworld/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/palworld/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/palworld/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/palworld/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/palworld/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/palworld/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/palworld/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/palworld/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/palworld/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/palworld/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/palworld/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/palworld/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/paperless-ngx/app.yaml b/ix-dev/community/paperless-ngx/app.yaml index c460c5e98b..dacd8cb2af 100644 --- a/ix-dev/community/paperless-ngx/app.yaml +++ b/ix-dev/community/paperless-ngx/app.yaml @@ -20,8 +20,8 @@ icon: https://media.sys.truenas.net/apps/paperless-ngx/icons/icon.svg keywords: - document - management -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -67,4 +67,4 @@ sources: - https://github.com/paperless-ngx/paperless-ngx title: Paperless-ngx train: community -version: 1.2.9 +version: 1.2.10 diff --git a/ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/container.py b/ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/ports.py b/ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/configs.py b/ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/container.py b/ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/depends.py b/ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/deps.py b/ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/device.py b/ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/device.py rename to ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/devices.py b/ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/dns.py b/ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/environment.py b/ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/error.py b/ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/error.py rename to ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/expose.py b/ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/functions.py b/ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/labels.py b/ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/notes.py b/ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/portal.py b/ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/portals.py b/ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/ports.py b/ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/render.py b/ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/render.py rename to ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/resources.py b/ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/restart.py b/ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/storage.py b/ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/validations.py b/ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/paperless-ngx/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/paperless-ngx/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/passbolt/app.yaml b/ix-dev/community/passbolt/app.yaml index 33ef073b5b..f22d39a39d 100644 --- a/ix-dev/community/passbolt/app.yaml +++ b/ix-dev/community/passbolt/app.yaml @@ -9,8 +9,8 @@ icon: https://media.sys.truenas.net/apps/passbolt/icons/icon.svg keywords: - password - manager -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -37,4 +37,4 @@ sources: - https://www.passbolt.com title: Passbolt train: community -version: 1.1.5 +version: 1.1.6 diff --git a/ix-dev/community/passbolt/templates/library/base_v2_1_8/container.py b/ix-dev/community/passbolt/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/passbolt/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/passbolt/templates/library/base_v2_1_8/ports.py b/ix-dev/community/passbolt/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/passbolt/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/passbolt/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/passbolt/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/passbolt/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/passbolt/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/passbolt/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/passbolt/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/passbolt/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/passbolt/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/passbolt/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/passbolt/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/passbolt/templates/library/base_v2_1_8/configs.py b/ix-dev/community/passbolt/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/passbolt/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/passbolt/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/passbolt/templates/library/base_v2_1_9/container.py b/ix-dev/community/passbolt/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/passbolt/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/passbolt/templates/library/base_v2_1_8/depends.py b/ix-dev/community/passbolt/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/passbolt/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/passbolt/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/passbolt/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/passbolt/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/passbolt/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/passbolt/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/passbolt/templates/library/base_v2_1_8/deps.py b/ix-dev/community/passbolt/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/passbolt/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/passbolt/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/passbolt/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/passbolt/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/passbolt/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/passbolt/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/passbolt/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/passbolt/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/passbolt/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/passbolt/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/passbolt/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/passbolt/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/passbolt/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/passbolt/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/passbolt/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/passbolt/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/passbolt/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/passbolt/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/passbolt/templates/library/base_v2_1_8/device.py b/ix-dev/community/passbolt/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/passbolt/templates/library/base_v2_1_8/device.py rename to ix-dev/community/passbolt/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/passbolt/templates/library/base_v2_1_8/devices.py b/ix-dev/community/passbolt/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/passbolt/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/passbolt/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/passbolt/templates/library/base_v2_1_8/dns.py b/ix-dev/community/passbolt/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/passbolt/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/passbolt/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/passbolt/templates/library/base_v2_1_8/environment.py b/ix-dev/community/passbolt/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/passbolt/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/passbolt/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/passbolt/templates/library/base_v2_1_8/error.py b/ix-dev/community/passbolt/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/passbolt/templates/library/base_v2_1_8/error.py rename to ix-dev/community/passbolt/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/passbolt/templates/library/base_v2_1_8/expose.py b/ix-dev/community/passbolt/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/passbolt/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/passbolt/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/passbolt/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/passbolt/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/passbolt/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/passbolt/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/passbolt/templates/library/base_v2_1_8/functions.py b/ix-dev/community/passbolt/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/passbolt/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/passbolt/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/passbolt/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/passbolt/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/passbolt/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/passbolt/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/passbolt/templates/library/base_v2_1_8/labels.py b/ix-dev/community/passbolt/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/passbolt/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/passbolt/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/passbolt/templates/library/base_v2_1_8/notes.py b/ix-dev/community/passbolt/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/passbolt/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/passbolt/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/passbolt/templates/library/base_v2_1_8/portal.py b/ix-dev/community/passbolt/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/passbolt/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/passbolt/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/passbolt/templates/library/base_v2_1_8/portals.py b/ix-dev/community/passbolt/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/passbolt/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/passbolt/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/passbolt/templates/library/base_v2_1_9/ports.py b/ix-dev/community/passbolt/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/passbolt/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/passbolt/templates/library/base_v2_1_8/render.py b/ix-dev/community/passbolt/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/passbolt/templates/library/base_v2_1_8/render.py rename to ix-dev/community/passbolt/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/passbolt/templates/library/base_v2_1_8/resources.py b/ix-dev/community/passbolt/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/passbolt/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/passbolt/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/passbolt/templates/library/base_v2_1_8/restart.py b/ix-dev/community/passbolt/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/passbolt/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/passbolt/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/passbolt/templates/library/base_v2_1_8/storage.py b/ix-dev/community/passbolt/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/passbolt/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/passbolt/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/passbolt/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/passbolt/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/passbolt/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/passbolt/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/passbolt/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/passbolt/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/passbolt/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/passbolt/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/passbolt/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/passbolt/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/passbolt/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/passbolt/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/passbolt/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/passbolt/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/passbolt/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/passbolt/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/passbolt/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/passbolt/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/passbolt/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/passbolt/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/passbolt/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/passbolt/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/passbolt/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/passbolt/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/passbolt/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/passbolt/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/passbolt/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/passbolt/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/passbolt/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/passbolt/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/passbolt/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/passbolt/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/passbolt/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/passbolt/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/passbolt/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/passbolt/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/passbolt/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/passbolt/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/passbolt/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/passbolt/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/passbolt/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/passbolt/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/passbolt/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/passbolt/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/passbolt/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/passbolt/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/passbolt/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/passbolt/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/passbolt/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/passbolt/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/passbolt/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/passbolt/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/passbolt/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/passbolt/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/passbolt/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/passbolt/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/passbolt/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/passbolt/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/passbolt/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/passbolt/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/passbolt/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/passbolt/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/passbolt/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/passbolt/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/passbolt/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/passbolt/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/passbolt/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/passbolt/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/passbolt/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/passbolt/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/passbolt/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/passbolt/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/passbolt/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/passbolt/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/passbolt/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/passbolt/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/passbolt/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/passbolt/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/passbolt/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/passbolt/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/passbolt/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/passbolt/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/passbolt/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/passbolt/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/passbolt/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/passbolt/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/passbolt/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/passbolt/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/passbolt/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/passbolt/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/passbolt/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/passbolt/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/passbolt/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/passbolt/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/passbolt/templates/library/base_v2_1_8/validations.py b/ix-dev/community/passbolt/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/passbolt/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/passbolt/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/passbolt/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/passbolt/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/passbolt/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/passbolt/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/passbolt/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/passbolt/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/passbolt/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/passbolt/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/passbolt/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/passbolt/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/passbolt/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/passbolt/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/passbolt/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/passbolt/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/passbolt/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/passbolt/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/passbolt/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/passbolt/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/passbolt/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/passbolt/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/penpot/app.yaml b/ix-dev/community/penpot/app.yaml index 75096b61b5..4945ae4a13 100644 --- a/ix-dev/community/penpot/app.yaml +++ b/ix-dev/community/penpot/app.yaml @@ -8,8 +8,8 @@ host_mounts: [] icon: https://media.sys.truenas.net/apps/penpot/icons/icon.svg keywords: - design -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -42,4 +42,4 @@ sources: - https://github.com/penpot/penpot title: Penpot train: community -version: 1.1.7 +version: 1.1.8 diff --git a/ix-dev/community/penpot/templates/library/base_v2_1_8/container.py b/ix-dev/community/penpot/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/penpot/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/penpot/templates/library/base_v2_1_8/ports.py b/ix-dev/community/penpot/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/penpot/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/penpot/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/penpot/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/penpot/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/penpot/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/penpot/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/penpot/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/penpot/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/penpot/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/penpot/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/penpot/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/penpot/templates/library/base_v2_1_8/configs.py b/ix-dev/community/penpot/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/penpot/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/penpot/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/penpot/templates/library/base_v2_1_9/container.py b/ix-dev/community/penpot/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/penpot/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/penpot/templates/library/base_v2_1_8/depends.py b/ix-dev/community/penpot/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/penpot/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/penpot/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/penpot/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/penpot/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/penpot/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/penpot/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/penpot/templates/library/base_v2_1_8/deps.py b/ix-dev/community/penpot/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/penpot/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/penpot/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/penpot/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/penpot/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/penpot/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/penpot/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/penpot/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/penpot/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/penpot/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/penpot/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/penpot/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/penpot/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/penpot/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/penpot/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/penpot/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/penpot/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/penpot/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/penpot/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/penpot/templates/library/base_v2_1_8/device.py b/ix-dev/community/penpot/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/penpot/templates/library/base_v2_1_8/device.py rename to ix-dev/community/penpot/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/penpot/templates/library/base_v2_1_8/devices.py b/ix-dev/community/penpot/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/penpot/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/penpot/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/penpot/templates/library/base_v2_1_8/dns.py b/ix-dev/community/penpot/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/penpot/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/penpot/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/penpot/templates/library/base_v2_1_8/environment.py b/ix-dev/community/penpot/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/penpot/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/penpot/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/penpot/templates/library/base_v2_1_8/error.py b/ix-dev/community/penpot/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/penpot/templates/library/base_v2_1_8/error.py rename to ix-dev/community/penpot/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/penpot/templates/library/base_v2_1_8/expose.py b/ix-dev/community/penpot/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/penpot/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/penpot/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/penpot/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/penpot/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/penpot/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/penpot/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/penpot/templates/library/base_v2_1_8/functions.py b/ix-dev/community/penpot/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/penpot/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/penpot/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/penpot/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/penpot/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/penpot/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/penpot/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/penpot/templates/library/base_v2_1_8/labels.py b/ix-dev/community/penpot/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/penpot/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/penpot/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/penpot/templates/library/base_v2_1_8/notes.py b/ix-dev/community/penpot/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/penpot/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/penpot/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/penpot/templates/library/base_v2_1_8/portal.py b/ix-dev/community/penpot/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/penpot/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/penpot/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/penpot/templates/library/base_v2_1_8/portals.py b/ix-dev/community/penpot/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/penpot/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/penpot/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/penpot/templates/library/base_v2_1_9/ports.py b/ix-dev/community/penpot/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/penpot/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/penpot/templates/library/base_v2_1_8/render.py b/ix-dev/community/penpot/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/penpot/templates/library/base_v2_1_8/render.py rename to ix-dev/community/penpot/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/penpot/templates/library/base_v2_1_8/resources.py b/ix-dev/community/penpot/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/penpot/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/penpot/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/penpot/templates/library/base_v2_1_8/restart.py b/ix-dev/community/penpot/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/penpot/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/penpot/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/penpot/templates/library/base_v2_1_8/storage.py b/ix-dev/community/penpot/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/penpot/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/penpot/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/penpot/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/penpot/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/penpot/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/penpot/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/penpot/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/penpot/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/penpot/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/penpot/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/penpot/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/penpot/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/penpot/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/penpot/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/penpot/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/penpot/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/penpot/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/penpot/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/penpot/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/penpot/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/penpot/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/penpot/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/penpot/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/penpot/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/penpot/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/penpot/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/penpot/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/penpot/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/penpot/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/penpot/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/penpot/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/penpot/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/penpot/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/penpot/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/penpot/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/penpot/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/penpot/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/penpot/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/penpot/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/penpot/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/penpot/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/penpot/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/penpot/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/penpot/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/penpot/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/penpot/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/penpot/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/penpot/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/penpot/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/penpot/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/penpot/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/penpot/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/penpot/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/penpot/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/penpot/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/penpot/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/penpot/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/penpot/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/penpot/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/penpot/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/penpot/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/penpot/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/penpot/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/penpot/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/penpot/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/penpot/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/penpot/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/penpot/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/penpot/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/penpot/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/penpot/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/penpot/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/penpot/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/penpot/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/penpot/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/penpot/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/penpot/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/penpot/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/penpot/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/penpot/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/penpot/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/penpot/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/penpot/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/penpot/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/penpot/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/penpot/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/penpot/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/penpot/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/penpot/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/penpot/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/penpot/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/penpot/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/penpot/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/penpot/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/penpot/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/penpot/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/penpot/templates/library/base_v2_1_8/validations.py b/ix-dev/community/penpot/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/penpot/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/penpot/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/penpot/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/penpot/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/penpot/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/penpot/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/penpot/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/penpot/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/penpot/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/penpot/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/penpot/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/penpot/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/penpot/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/penpot/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/penpot/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/penpot/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/penpot/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/penpot/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/penpot/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/penpot/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/penpot/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/penpot/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/pgadmin/app.yaml b/ix-dev/community/pgadmin/app.yaml index aea068af31..983d4f7a12 100644 --- a/ix-dev/community/pgadmin/app.yaml +++ b/ix-dev/community/pgadmin/app.yaml @@ -12,8 +12,8 @@ icon: https://media.sys.truenas.net/apps/pgadmin/icons/icon.png keywords: - database - management -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -34,4 +34,4 @@ sources: - https://www.pgadmin.org/ title: pgAdmin train: community -version: 1.1.6 +version: 1.1.7 diff --git a/ix-dev/community/pgadmin/templates/library/base_v2_1_8/container.py b/ix-dev/community/pgadmin/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/pgadmin/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/pgadmin/templates/library/base_v2_1_8/ports.py b/ix-dev/community/pgadmin/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/pgadmin/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/pgadmin/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/pgadmin/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/pgadmin/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/pgadmin/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/pgadmin/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/pgadmin/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/pgadmin/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/pgadmin/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/pgadmin/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/pgadmin/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/pgadmin/templates/library/base_v2_1_8/configs.py b/ix-dev/community/pgadmin/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/pgadmin/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/pgadmin/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/pgadmin/templates/library/base_v2_1_9/container.py b/ix-dev/community/pgadmin/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/pgadmin/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/pgadmin/templates/library/base_v2_1_8/depends.py b/ix-dev/community/pgadmin/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/pgadmin/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/pgadmin/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/pgadmin/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/pgadmin/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/pgadmin/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/pgadmin/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/pgadmin/templates/library/base_v2_1_8/deps.py b/ix-dev/community/pgadmin/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/pgadmin/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/pgadmin/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/pgadmin/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/pgadmin/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/pgadmin/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/pgadmin/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/pgadmin/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/pgadmin/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/pgadmin/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/pgadmin/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/pgadmin/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/pgadmin/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/pgadmin/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/pgadmin/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/pgadmin/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/pgadmin/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/pgadmin/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/pgadmin/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/pgadmin/templates/library/base_v2_1_8/device.py b/ix-dev/community/pgadmin/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/pgadmin/templates/library/base_v2_1_8/device.py rename to ix-dev/community/pgadmin/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/pgadmin/templates/library/base_v2_1_8/devices.py b/ix-dev/community/pgadmin/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/pgadmin/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/pgadmin/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/pgadmin/templates/library/base_v2_1_8/dns.py b/ix-dev/community/pgadmin/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/pgadmin/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/pgadmin/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/pgadmin/templates/library/base_v2_1_8/environment.py b/ix-dev/community/pgadmin/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/pgadmin/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/pgadmin/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/pgadmin/templates/library/base_v2_1_8/error.py b/ix-dev/community/pgadmin/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/pgadmin/templates/library/base_v2_1_8/error.py rename to ix-dev/community/pgadmin/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/pgadmin/templates/library/base_v2_1_8/expose.py b/ix-dev/community/pgadmin/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/pgadmin/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/pgadmin/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/pgadmin/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/pgadmin/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/pgadmin/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/pgadmin/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/pgadmin/templates/library/base_v2_1_8/functions.py b/ix-dev/community/pgadmin/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/pgadmin/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/pgadmin/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/pgadmin/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/pgadmin/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/pgadmin/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/pgadmin/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/pgadmin/templates/library/base_v2_1_8/labels.py b/ix-dev/community/pgadmin/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/pgadmin/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/pgadmin/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/pgadmin/templates/library/base_v2_1_8/notes.py b/ix-dev/community/pgadmin/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/pgadmin/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/pgadmin/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/pgadmin/templates/library/base_v2_1_8/portal.py b/ix-dev/community/pgadmin/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/pgadmin/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/pgadmin/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/pgadmin/templates/library/base_v2_1_8/portals.py b/ix-dev/community/pgadmin/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/pgadmin/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/pgadmin/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/pgadmin/templates/library/base_v2_1_9/ports.py b/ix-dev/community/pgadmin/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/pgadmin/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/pgadmin/templates/library/base_v2_1_8/render.py b/ix-dev/community/pgadmin/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/pgadmin/templates/library/base_v2_1_8/render.py rename to ix-dev/community/pgadmin/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/pgadmin/templates/library/base_v2_1_8/resources.py b/ix-dev/community/pgadmin/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/pgadmin/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/pgadmin/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/pgadmin/templates/library/base_v2_1_8/restart.py b/ix-dev/community/pgadmin/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/pgadmin/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/pgadmin/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/pgadmin/templates/library/base_v2_1_8/storage.py b/ix-dev/community/pgadmin/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/pgadmin/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/pgadmin/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/pgadmin/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/pgadmin/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/pgadmin/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/pgadmin/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/pgadmin/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/pgadmin/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/pgadmin/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/pgadmin/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/pgadmin/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/pgadmin/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/pgadmin/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/pgadmin/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/pgadmin/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/pgadmin/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/pgadmin/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/pgadmin/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/pgadmin/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/pgadmin/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/pgadmin/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/pgadmin/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/pgadmin/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/pgadmin/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/pgadmin/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/pgadmin/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/pgadmin/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/pgadmin/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/pgadmin/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/pgadmin/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/pgadmin/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/pgadmin/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/pgadmin/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/pgadmin/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/pgadmin/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/pgadmin/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/pgadmin/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/pgadmin/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/pgadmin/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/pgadmin/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/pgadmin/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/pgadmin/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/pgadmin/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/pgadmin/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/pgadmin/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/pgadmin/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/pgadmin/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/pgadmin/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/pgadmin/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/pgadmin/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/pgadmin/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/pgadmin/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/pgadmin/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/pgadmin/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/pgadmin/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/pgadmin/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/pgadmin/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/pgadmin/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/pgadmin/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/pgadmin/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/pgadmin/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/pgadmin/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/pgadmin/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/pgadmin/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/pgadmin/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/pgadmin/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/pgadmin/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/pgadmin/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/pgadmin/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/pgadmin/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/pgadmin/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/pgadmin/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/pgadmin/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/pgadmin/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/pgadmin/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/pgadmin/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/pgadmin/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/pgadmin/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/pgadmin/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/pgadmin/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/pgadmin/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/pgadmin/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/pgadmin/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/pgadmin/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/pgadmin/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/pgadmin/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/pgadmin/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/pgadmin/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/pgadmin/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/pgadmin/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/pgadmin/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/pgadmin/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/pgadmin/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/pgadmin/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/pgadmin/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/pgadmin/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/pgadmin/templates/library/base_v2_1_8/validations.py b/ix-dev/community/pgadmin/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/pgadmin/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/pgadmin/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/pgadmin/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/pgadmin/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/pgadmin/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/pgadmin/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/pgadmin/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/pgadmin/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/pgadmin/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/pgadmin/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/pgadmin/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/pgadmin/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/pgadmin/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/pgadmin/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/pgadmin/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/pgadmin/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/pgadmin/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/pgadmin/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/pgadmin/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/pgadmin/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/pgadmin/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/pgadmin/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/pigallery2/app.yaml b/ix-dev/community/pigallery2/app.yaml index a0a09cd304..cc997fd7bb 100644 --- a/ix-dev/community/pigallery2/app.yaml +++ b/ix-dev/community/pigallery2/app.yaml @@ -10,8 +10,8 @@ icon: https://media.sys.truenas.net/apps/pigallery2/icons/icon.png keywords: - photo - media -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -39,4 +39,4 @@ sources: - https://github.com/bpatrik/pigallery2 title: PiGallery2 train: community -version: 1.1.5 +version: 1.1.6 diff --git a/ix-dev/community/pigallery2/templates/library/base_v2_1_8/container.py b/ix-dev/community/pigallery2/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/pigallery2/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/pigallery2/templates/library/base_v2_1_8/ports.py b/ix-dev/community/pigallery2/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/pigallery2/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/pigallery2/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/pigallery2/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/pigallery2/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/pigallery2/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/pigallery2/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/pigallery2/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/pigallery2/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/pigallery2/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/pigallery2/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/pigallery2/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/pigallery2/templates/library/base_v2_1_8/configs.py b/ix-dev/community/pigallery2/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/pigallery2/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/pigallery2/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/pigallery2/templates/library/base_v2_1_9/container.py b/ix-dev/community/pigallery2/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/pigallery2/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/pigallery2/templates/library/base_v2_1_8/depends.py b/ix-dev/community/pigallery2/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/pigallery2/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/pigallery2/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/pigallery2/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/pigallery2/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/pigallery2/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/pigallery2/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/pigallery2/templates/library/base_v2_1_8/deps.py b/ix-dev/community/pigallery2/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/pigallery2/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/pigallery2/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/pigallery2/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/pigallery2/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/pigallery2/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/pigallery2/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/pigallery2/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/pigallery2/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/pigallery2/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/pigallery2/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/pigallery2/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/pigallery2/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/pigallery2/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/pigallery2/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/pigallery2/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/pigallery2/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/pigallery2/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/pigallery2/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/pigallery2/templates/library/base_v2_1_8/device.py b/ix-dev/community/pigallery2/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/pigallery2/templates/library/base_v2_1_8/device.py rename to ix-dev/community/pigallery2/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/pigallery2/templates/library/base_v2_1_8/devices.py b/ix-dev/community/pigallery2/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/pigallery2/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/pigallery2/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/pigallery2/templates/library/base_v2_1_8/dns.py b/ix-dev/community/pigallery2/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/pigallery2/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/pigallery2/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/pigallery2/templates/library/base_v2_1_8/environment.py b/ix-dev/community/pigallery2/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/pigallery2/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/pigallery2/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/pigallery2/templates/library/base_v2_1_8/error.py b/ix-dev/community/pigallery2/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/pigallery2/templates/library/base_v2_1_8/error.py rename to ix-dev/community/pigallery2/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/pigallery2/templates/library/base_v2_1_8/expose.py b/ix-dev/community/pigallery2/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/pigallery2/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/pigallery2/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/pigallery2/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/pigallery2/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/pigallery2/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/pigallery2/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/pigallery2/templates/library/base_v2_1_8/functions.py b/ix-dev/community/pigallery2/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/pigallery2/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/pigallery2/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/pigallery2/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/pigallery2/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/pigallery2/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/pigallery2/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/pigallery2/templates/library/base_v2_1_8/labels.py b/ix-dev/community/pigallery2/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/pigallery2/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/pigallery2/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/pigallery2/templates/library/base_v2_1_8/notes.py b/ix-dev/community/pigallery2/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/pigallery2/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/pigallery2/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/pigallery2/templates/library/base_v2_1_8/portal.py b/ix-dev/community/pigallery2/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/pigallery2/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/pigallery2/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/pigallery2/templates/library/base_v2_1_8/portals.py b/ix-dev/community/pigallery2/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/pigallery2/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/pigallery2/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/pigallery2/templates/library/base_v2_1_9/ports.py b/ix-dev/community/pigallery2/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/pigallery2/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/pigallery2/templates/library/base_v2_1_8/render.py b/ix-dev/community/pigallery2/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/pigallery2/templates/library/base_v2_1_8/render.py rename to ix-dev/community/pigallery2/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/pigallery2/templates/library/base_v2_1_8/resources.py b/ix-dev/community/pigallery2/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/pigallery2/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/pigallery2/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/pigallery2/templates/library/base_v2_1_8/restart.py b/ix-dev/community/pigallery2/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/pigallery2/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/pigallery2/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/pigallery2/templates/library/base_v2_1_8/storage.py b/ix-dev/community/pigallery2/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/pigallery2/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/pigallery2/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/pigallery2/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/pigallery2/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/pigallery2/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/pigallery2/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/pigallery2/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/pigallery2/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/pigallery2/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/pigallery2/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/pigallery2/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/pigallery2/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/pigallery2/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/pigallery2/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/pigallery2/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/pigallery2/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/pigallery2/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/pigallery2/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/pigallery2/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/pigallery2/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/pigallery2/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/pigallery2/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/pigallery2/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/pigallery2/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/pigallery2/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/pigallery2/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/pigallery2/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/pigallery2/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/pigallery2/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/pigallery2/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/pigallery2/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/pigallery2/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/pigallery2/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/pigallery2/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/pigallery2/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/pigallery2/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/pigallery2/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/pigallery2/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/pigallery2/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/pigallery2/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/pigallery2/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/pigallery2/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/pigallery2/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/pigallery2/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/pigallery2/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/pigallery2/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/pigallery2/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/pigallery2/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/pigallery2/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/pigallery2/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/pigallery2/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/pigallery2/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/pigallery2/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/pigallery2/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/pigallery2/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/pigallery2/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/pigallery2/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/pigallery2/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/pigallery2/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/pigallery2/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/pigallery2/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/pigallery2/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/pigallery2/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/pigallery2/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/pigallery2/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/pigallery2/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/pigallery2/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/pigallery2/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/pigallery2/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/pigallery2/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/pigallery2/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/pigallery2/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/pigallery2/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/pigallery2/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/pigallery2/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/pigallery2/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/pigallery2/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/pigallery2/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/pigallery2/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/pigallery2/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/pigallery2/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/pigallery2/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/pigallery2/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/pigallery2/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/pigallery2/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/pigallery2/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/pigallery2/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/pigallery2/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/pigallery2/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/pigallery2/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/pigallery2/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/pigallery2/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/pigallery2/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/pigallery2/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/pigallery2/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/pigallery2/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/pigallery2/templates/library/base_v2_1_8/validations.py b/ix-dev/community/pigallery2/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/pigallery2/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/pigallery2/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/pigallery2/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/pigallery2/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/pigallery2/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/pigallery2/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/pigallery2/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/pigallery2/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/pigallery2/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/pigallery2/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/pigallery2/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/pigallery2/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/pigallery2/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/pigallery2/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/pigallery2/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/pigallery2/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/pigallery2/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/pigallery2/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/pigallery2/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/pigallery2/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/pigallery2/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/pigallery2/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/piwigo/app.yaml b/ix-dev/community/piwigo/app.yaml index 9697a2efce..e744a93baf 100644 --- a/ix-dev/community/piwigo/app.yaml +++ b/ix-dev/community/piwigo/app.yaml @@ -22,8 +22,8 @@ icon: https://media.sys.truenas.net/apps/piwigo/icons/icon.svg keywords: - photo - gallery -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -47,4 +47,4 @@ sources: - https://hub.docker.com/r/linuxserver/piwigo title: Piwigo train: community -version: 1.1.6 +version: 1.1.7 diff --git a/ix-dev/community/piwigo/templates/library/base_v2_1_8/container.py b/ix-dev/community/piwigo/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/piwigo/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/piwigo/templates/library/base_v2_1_8/ports.py b/ix-dev/community/piwigo/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/piwigo/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/piwigo/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/piwigo/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/piwigo/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/piwigo/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/piwigo/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/piwigo/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/piwigo/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/piwigo/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/piwigo/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/piwigo/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/piwigo/templates/library/base_v2_1_8/configs.py b/ix-dev/community/piwigo/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/piwigo/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/piwigo/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/piwigo/templates/library/base_v2_1_9/container.py b/ix-dev/community/piwigo/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/piwigo/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/piwigo/templates/library/base_v2_1_8/depends.py b/ix-dev/community/piwigo/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/piwigo/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/piwigo/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/piwigo/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/piwigo/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/piwigo/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/piwigo/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/piwigo/templates/library/base_v2_1_8/deps.py b/ix-dev/community/piwigo/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/piwigo/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/piwigo/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/piwigo/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/piwigo/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/piwigo/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/piwigo/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/piwigo/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/piwigo/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/piwigo/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/piwigo/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/piwigo/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/piwigo/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/piwigo/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/piwigo/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/piwigo/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/piwigo/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/piwigo/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/piwigo/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/piwigo/templates/library/base_v2_1_8/device.py b/ix-dev/community/piwigo/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/piwigo/templates/library/base_v2_1_8/device.py rename to ix-dev/community/piwigo/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/piwigo/templates/library/base_v2_1_8/devices.py b/ix-dev/community/piwigo/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/piwigo/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/piwigo/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/piwigo/templates/library/base_v2_1_8/dns.py b/ix-dev/community/piwigo/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/piwigo/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/piwigo/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/piwigo/templates/library/base_v2_1_8/environment.py b/ix-dev/community/piwigo/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/piwigo/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/piwigo/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/piwigo/templates/library/base_v2_1_8/error.py b/ix-dev/community/piwigo/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/piwigo/templates/library/base_v2_1_8/error.py rename to ix-dev/community/piwigo/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/piwigo/templates/library/base_v2_1_8/expose.py b/ix-dev/community/piwigo/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/piwigo/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/piwigo/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/piwigo/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/piwigo/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/piwigo/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/piwigo/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/piwigo/templates/library/base_v2_1_8/functions.py b/ix-dev/community/piwigo/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/piwigo/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/piwigo/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/piwigo/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/piwigo/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/piwigo/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/piwigo/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/piwigo/templates/library/base_v2_1_8/labels.py b/ix-dev/community/piwigo/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/piwigo/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/piwigo/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/piwigo/templates/library/base_v2_1_8/notes.py b/ix-dev/community/piwigo/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/piwigo/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/piwigo/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/piwigo/templates/library/base_v2_1_8/portal.py b/ix-dev/community/piwigo/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/piwigo/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/piwigo/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/piwigo/templates/library/base_v2_1_8/portals.py b/ix-dev/community/piwigo/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/piwigo/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/piwigo/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/piwigo/templates/library/base_v2_1_9/ports.py b/ix-dev/community/piwigo/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/piwigo/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/piwigo/templates/library/base_v2_1_8/render.py b/ix-dev/community/piwigo/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/piwigo/templates/library/base_v2_1_8/render.py rename to ix-dev/community/piwigo/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/piwigo/templates/library/base_v2_1_8/resources.py b/ix-dev/community/piwigo/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/piwigo/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/piwigo/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/piwigo/templates/library/base_v2_1_8/restart.py b/ix-dev/community/piwigo/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/piwigo/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/piwigo/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/piwigo/templates/library/base_v2_1_8/storage.py b/ix-dev/community/piwigo/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/piwigo/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/piwigo/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/piwigo/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/piwigo/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/piwigo/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/piwigo/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/piwigo/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/piwigo/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/piwigo/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/piwigo/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/piwigo/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/piwigo/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/piwigo/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/piwigo/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/piwigo/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/piwigo/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/piwigo/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/piwigo/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/piwigo/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/piwigo/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/piwigo/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/piwigo/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/piwigo/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/piwigo/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/piwigo/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/piwigo/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/piwigo/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/piwigo/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/piwigo/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/piwigo/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/piwigo/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/piwigo/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/piwigo/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/piwigo/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/piwigo/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/piwigo/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/piwigo/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/piwigo/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/piwigo/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/piwigo/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/piwigo/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/piwigo/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/piwigo/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/piwigo/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/piwigo/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/piwigo/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/piwigo/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/piwigo/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/piwigo/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/piwigo/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/piwigo/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/piwigo/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/piwigo/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/piwigo/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/piwigo/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/piwigo/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/piwigo/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/piwigo/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/piwigo/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/piwigo/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/piwigo/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/piwigo/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/piwigo/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/piwigo/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/piwigo/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/piwigo/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/piwigo/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/piwigo/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/piwigo/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/piwigo/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/piwigo/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/piwigo/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/piwigo/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/piwigo/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/piwigo/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/piwigo/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/piwigo/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/piwigo/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/piwigo/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/piwigo/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/piwigo/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/piwigo/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/piwigo/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/piwigo/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/piwigo/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/piwigo/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/piwigo/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/piwigo/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/piwigo/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/piwigo/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/piwigo/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/piwigo/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/piwigo/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/piwigo/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/piwigo/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/piwigo/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/piwigo/templates/library/base_v2_1_8/validations.py b/ix-dev/community/piwigo/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/piwigo/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/piwigo/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/piwigo/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/piwigo/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/piwigo/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/piwigo/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/piwigo/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/piwigo/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/piwigo/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/piwigo/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/piwigo/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/piwigo/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/piwigo/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/piwigo/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/piwigo/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/piwigo/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/piwigo/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/piwigo/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/piwigo/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/piwigo/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/piwigo/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/piwigo/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/planka/app.yaml b/ix-dev/community/planka/app.yaml index ba200e30d9..db3aeeb767 100644 --- a/ix-dev/community/planka/app.yaml +++ b/ix-dev/community/planka/app.yaml @@ -10,8 +10,8 @@ keywords: - kanban - project - task -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -35,4 +35,4 @@ sources: - https://github.com/plankanban/planka title: Planka train: community -version: 1.2.4 +version: 1.2.5 diff --git a/ix-dev/community/planka/templates/library/base_v2_1_8/container.py b/ix-dev/community/planka/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/planka/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/planka/templates/library/base_v2_1_8/ports.py b/ix-dev/community/planka/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/planka/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/planka/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/planka/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/planka/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/planka/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/planka/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/planka/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/planka/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/planka/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/planka/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/planka/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/planka/templates/library/base_v2_1_8/configs.py b/ix-dev/community/planka/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/planka/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/planka/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/planka/templates/library/base_v2_1_9/container.py b/ix-dev/community/planka/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/planka/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/planka/templates/library/base_v2_1_8/depends.py b/ix-dev/community/planka/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/planka/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/planka/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/planka/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/planka/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/planka/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/planka/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/planka/templates/library/base_v2_1_8/deps.py b/ix-dev/community/planka/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/planka/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/planka/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/planka/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/planka/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/planka/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/planka/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/planka/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/planka/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/planka/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/planka/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/planka/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/planka/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/planka/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/planka/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/planka/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/planka/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/planka/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/planka/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/planka/templates/library/base_v2_1_8/device.py b/ix-dev/community/planka/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/planka/templates/library/base_v2_1_8/device.py rename to ix-dev/community/planka/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/planka/templates/library/base_v2_1_8/devices.py b/ix-dev/community/planka/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/planka/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/planka/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/planka/templates/library/base_v2_1_8/dns.py b/ix-dev/community/planka/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/planka/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/planka/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/planka/templates/library/base_v2_1_8/environment.py b/ix-dev/community/planka/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/planka/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/planka/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/planka/templates/library/base_v2_1_8/error.py b/ix-dev/community/planka/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/planka/templates/library/base_v2_1_8/error.py rename to ix-dev/community/planka/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/planka/templates/library/base_v2_1_8/expose.py b/ix-dev/community/planka/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/planka/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/planka/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/planka/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/planka/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/planka/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/planka/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/planka/templates/library/base_v2_1_8/functions.py b/ix-dev/community/planka/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/planka/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/planka/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/planka/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/planka/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/planka/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/planka/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/planka/templates/library/base_v2_1_8/labels.py b/ix-dev/community/planka/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/planka/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/planka/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/planka/templates/library/base_v2_1_8/notes.py b/ix-dev/community/planka/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/planka/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/planka/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/planka/templates/library/base_v2_1_8/portal.py b/ix-dev/community/planka/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/planka/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/planka/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/planka/templates/library/base_v2_1_8/portals.py b/ix-dev/community/planka/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/planka/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/planka/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/planka/templates/library/base_v2_1_9/ports.py b/ix-dev/community/planka/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/planka/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/planka/templates/library/base_v2_1_8/render.py b/ix-dev/community/planka/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/planka/templates/library/base_v2_1_8/render.py rename to ix-dev/community/planka/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/planka/templates/library/base_v2_1_8/resources.py b/ix-dev/community/planka/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/planka/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/planka/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/planka/templates/library/base_v2_1_8/restart.py b/ix-dev/community/planka/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/planka/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/planka/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/planka/templates/library/base_v2_1_8/storage.py b/ix-dev/community/planka/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/planka/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/planka/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/planka/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/planka/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/planka/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/planka/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/planka/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/planka/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/planka/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/planka/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/planka/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/planka/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/planka/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/planka/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/planka/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/planka/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/planka/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/planka/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/planka/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/planka/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/planka/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/planka/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/planka/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/planka/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/planka/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/planka/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/planka/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/planka/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/planka/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/planka/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/planka/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/planka/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/planka/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/planka/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/planka/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/planka/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/planka/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/planka/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/planka/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/planka/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/planka/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/planka/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/planka/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/planka/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/planka/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/planka/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/planka/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/planka/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/planka/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/planka/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/planka/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/planka/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/planka/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/planka/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/planka/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/planka/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/planka/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/planka/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/planka/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/planka/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/planka/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/planka/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/planka/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/planka/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/planka/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/planka/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/planka/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/planka/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/planka/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/planka/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/planka/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/planka/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/planka/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/planka/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/planka/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/planka/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/planka/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/planka/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/planka/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/planka/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/planka/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/planka/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/planka/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/planka/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/planka/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/planka/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/planka/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/planka/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/planka/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/planka/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/planka/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/planka/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/planka/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/planka/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/planka/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/planka/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/planka/templates/library/base_v2_1_8/validations.py b/ix-dev/community/planka/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/planka/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/planka/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/planka/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/planka/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/planka/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/planka/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/planka/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/planka/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/planka/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/planka/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/planka/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/planka/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/planka/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/planka/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/planka/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/planka/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/planka/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/planka/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/planka/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/planka/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/planka/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/planka/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/plex-auto-languages/app.yaml b/ix-dev/community/plex-auto-languages/app.yaml index 9865d27c48..a876a0980c 100644 --- a/ix-dev/community/plex-auto-languages/app.yaml +++ b/ix-dev/community/plex-auto-languages/app.yaml @@ -9,8 +9,8 @@ icon: https://media.sys.truenas.net/apps/plex-auto-languages/icons/icon.svg keywords: - plex - languages -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -27,4 +27,4 @@ sources: - https://github.com/JourneyDocker/Plex-Auto-Languages title: Plex Auto Languages train: community -version: 1.2.6 +version: 1.2.7 diff --git a/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/container.py b/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/ports.py b/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/configs.py b/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/container.py b/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/depends.py b/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/deps.py b/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/device.py b/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/device.py rename to ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/devices.py b/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/dns.py b/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/environment.py b/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/error.py b/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/error.py rename to ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/expose.py b/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/functions.py b/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/labels.py b/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/notes.py b/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/portal.py b/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/portals.py b/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/ports.py b/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/render.py b/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/render.py rename to ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/resources.py b/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/restart.py b/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/storage.py b/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/validations.py b/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/plex-auto-languages/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/plex-auto-languages/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/portainer/app.yaml b/ix-dev/community/portainer/app.yaml index dd9d43897b..ae6b9b5d06 100644 --- a/ix-dev/community/portainer/app.yaml +++ b/ix-dev/community/portainer/app.yaml @@ -28,8 +28,8 @@ keywords: - docker - compose - container -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -49,4 +49,4 @@ sources: - https://github.com/portainer/portainer title: Portainer train: community -version: 1.3.9 +version: 1.3.10 diff --git a/ix-dev/community/portainer/templates/library/base_v2_1_8/container.py b/ix-dev/community/portainer/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/portainer/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/portainer/templates/library/base_v2_1_8/ports.py b/ix-dev/community/portainer/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/portainer/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/portainer/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/portainer/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/portainer/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/portainer/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/portainer/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/portainer/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/portainer/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/portainer/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/portainer/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/portainer/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/portainer/templates/library/base_v2_1_8/configs.py b/ix-dev/community/portainer/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/portainer/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/portainer/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/portainer/templates/library/base_v2_1_9/container.py b/ix-dev/community/portainer/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/portainer/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/portainer/templates/library/base_v2_1_8/depends.py b/ix-dev/community/portainer/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/portainer/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/portainer/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/portainer/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/portainer/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/portainer/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/portainer/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/portainer/templates/library/base_v2_1_8/deps.py b/ix-dev/community/portainer/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/portainer/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/portainer/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/portainer/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/portainer/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/portainer/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/portainer/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/portainer/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/portainer/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/portainer/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/portainer/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/portainer/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/portainer/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/portainer/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/portainer/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/portainer/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/portainer/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/portainer/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/portainer/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/portainer/templates/library/base_v2_1_8/device.py b/ix-dev/community/portainer/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/portainer/templates/library/base_v2_1_8/device.py rename to ix-dev/community/portainer/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/portainer/templates/library/base_v2_1_8/devices.py b/ix-dev/community/portainer/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/portainer/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/portainer/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/portainer/templates/library/base_v2_1_8/dns.py b/ix-dev/community/portainer/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/portainer/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/portainer/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/portainer/templates/library/base_v2_1_8/environment.py b/ix-dev/community/portainer/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/portainer/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/portainer/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/portainer/templates/library/base_v2_1_8/error.py b/ix-dev/community/portainer/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/portainer/templates/library/base_v2_1_8/error.py rename to ix-dev/community/portainer/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/portainer/templates/library/base_v2_1_8/expose.py b/ix-dev/community/portainer/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/portainer/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/portainer/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/portainer/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/portainer/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/portainer/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/portainer/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/portainer/templates/library/base_v2_1_8/functions.py b/ix-dev/community/portainer/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/portainer/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/portainer/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/portainer/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/portainer/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/portainer/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/portainer/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/portainer/templates/library/base_v2_1_8/labels.py b/ix-dev/community/portainer/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/portainer/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/portainer/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/portainer/templates/library/base_v2_1_8/notes.py b/ix-dev/community/portainer/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/portainer/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/portainer/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/portainer/templates/library/base_v2_1_8/portal.py b/ix-dev/community/portainer/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/portainer/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/portainer/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/portainer/templates/library/base_v2_1_8/portals.py b/ix-dev/community/portainer/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/portainer/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/portainer/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/portainer/templates/library/base_v2_1_9/ports.py b/ix-dev/community/portainer/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/portainer/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/portainer/templates/library/base_v2_1_8/render.py b/ix-dev/community/portainer/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/portainer/templates/library/base_v2_1_8/render.py rename to ix-dev/community/portainer/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/portainer/templates/library/base_v2_1_8/resources.py b/ix-dev/community/portainer/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/portainer/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/portainer/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/portainer/templates/library/base_v2_1_8/restart.py b/ix-dev/community/portainer/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/portainer/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/portainer/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/portainer/templates/library/base_v2_1_8/storage.py b/ix-dev/community/portainer/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/portainer/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/portainer/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/portainer/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/portainer/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/portainer/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/portainer/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/portainer/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/portainer/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/portainer/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/portainer/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/portainer/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/portainer/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/portainer/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/portainer/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/portainer/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/portainer/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/portainer/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/portainer/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/portainer/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/portainer/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/portainer/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/portainer/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/portainer/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/portainer/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/portainer/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/portainer/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/portainer/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/portainer/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/portainer/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/portainer/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/portainer/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/portainer/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/portainer/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/portainer/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/portainer/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/portainer/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/portainer/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/portainer/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/portainer/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/portainer/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/portainer/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/portainer/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/portainer/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/portainer/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/portainer/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/portainer/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/portainer/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/portainer/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/portainer/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/portainer/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/portainer/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/portainer/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/portainer/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/portainer/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/portainer/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/portainer/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/portainer/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/portainer/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/portainer/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/portainer/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/portainer/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/portainer/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/portainer/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/portainer/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/portainer/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/portainer/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/portainer/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/portainer/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/portainer/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/portainer/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/portainer/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/portainer/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/portainer/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/portainer/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/portainer/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/portainer/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/portainer/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/portainer/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/portainer/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/portainer/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/portainer/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/portainer/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/portainer/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/portainer/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/portainer/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/portainer/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/portainer/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/portainer/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/portainer/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/portainer/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/portainer/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/portainer/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/portainer/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/portainer/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/portainer/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/portainer/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/portainer/templates/library/base_v2_1_8/validations.py b/ix-dev/community/portainer/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/portainer/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/portainer/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/portainer/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/portainer/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/portainer/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/portainer/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/portainer/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/portainer/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/portainer/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/portainer/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/portainer/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/portainer/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/portainer/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/portainer/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/portainer/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/portainer/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/portainer/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/portainer/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/portainer/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/portainer/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/portainer/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/portainer/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/postgres/app.yaml b/ix-dev/community/postgres/app.yaml index aa0ca31567..44eeb0a0ca 100644 --- a/ix-dev/community/postgres/app.yaml +++ b/ix-dev/community/postgres/app.yaml @@ -9,8 +9,8 @@ host_mounts: [] icon: https://media.sys.truenas.net/apps/postgres/icons/icon.png keywords: - database -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -27,4 +27,4 @@ sources: - https://hub.docker.com/_/postgres title: Postgres train: community -version: 1.0.13 +version: 1.0.14 diff --git a/ix-dev/community/postgres/templates/library/base_v2_1_8/container.py b/ix-dev/community/postgres/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/postgres/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/postgres/templates/library/base_v2_1_8/ports.py b/ix-dev/community/postgres/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/postgres/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/postgres/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/postgres/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/postgres/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/postgres/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/postgres/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/postgres/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/postgres/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/postgres/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/postgres/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/postgres/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/postgres/templates/library/base_v2_1_8/configs.py b/ix-dev/community/postgres/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/postgres/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/postgres/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/postgres/templates/library/base_v2_1_9/container.py b/ix-dev/community/postgres/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/postgres/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/postgres/templates/library/base_v2_1_8/depends.py b/ix-dev/community/postgres/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/postgres/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/postgres/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/postgres/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/postgres/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/postgres/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/postgres/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/postgres/templates/library/base_v2_1_8/deps.py b/ix-dev/community/postgres/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/postgres/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/postgres/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/postgres/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/postgres/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/postgres/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/postgres/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/postgres/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/postgres/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/postgres/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/postgres/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/postgres/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/postgres/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/postgres/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/postgres/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/postgres/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/postgres/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/postgres/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/postgres/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/postgres/templates/library/base_v2_1_8/device.py b/ix-dev/community/postgres/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/postgres/templates/library/base_v2_1_8/device.py rename to ix-dev/community/postgres/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/postgres/templates/library/base_v2_1_8/devices.py b/ix-dev/community/postgres/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/postgres/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/postgres/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/postgres/templates/library/base_v2_1_8/dns.py b/ix-dev/community/postgres/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/postgres/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/postgres/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/postgres/templates/library/base_v2_1_8/environment.py b/ix-dev/community/postgres/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/postgres/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/postgres/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/postgres/templates/library/base_v2_1_8/error.py b/ix-dev/community/postgres/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/postgres/templates/library/base_v2_1_8/error.py rename to ix-dev/community/postgres/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/postgres/templates/library/base_v2_1_8/expose.py b/ix-dev/community/postgres/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/postgres/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/postgres/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/postgres/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/postgres/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/postgres/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/postgres/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/postgres/templates/library/base_v2_1_8/functions.py b/ix-dev/community/postgres/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/postgres/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/postgres/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/postgres/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/postgres/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/postgres/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/postgres/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/postgres/templates/library/base_v2_1_8/labels.py b/ix-dev/community/postgres/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/postgres/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/postgres/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/postgres/templates/library/base_v2_1_8/notes.py b/ix-dev/community/postgres/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/postgres/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/postgres/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/postgres/templates/library/base_v2_1_8/portal.py b/ix-dev/community/postgres/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/postgres/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/postgres/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/postgres/templates/library/base_v2_1_8/portals.py b/ix-dev/community/postgres/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/postgres/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/postgres/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/postgres/templates/library/base_v2_1_9/ports.py b/ix-dev/community/postgres/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/postgres/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/postgres/templates/library/base_v2_1_8/render.py b/ix-dev/community/postgres/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/postgres/templates/library/base_v2_1_8/render.py rename to ix-dev/community/postgres/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/postgres/templates/library/base_v2_1_8/resources.py b/ix-dev/community/postgres/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/postgres/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/postgres/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/postgres/templates/library/base_v2_1_8/restart.py b/ix-dev/community/postgres/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/postgres/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/postgres/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/postgres/templates/library/base_v2_1_8/storage.py b/ix-dev/community/postgres/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/postgres/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/postgres/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/postgres/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/postgres/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/postgres/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/postgres/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/postgres/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/postgres/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/postgres/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/postgres/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/postgres/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/postgres/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/postgres/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/postgres/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/postgres/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/postgres/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/postgres/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/postgres/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/postgres/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/postgres/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/postgres/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/postgres/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/postgres/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/postgres/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/postgres/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/postgres/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/postgres/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/postgres/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/postgres/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/postgres/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/postgres/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/postgres/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/postgres/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/postgres/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/postgres/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/postgres/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/postgres/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/postgres/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/postgres/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/postgres/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/postgres/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/postgres/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/postgres/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/postgres/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/postgres/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/postgres/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/postgres/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/postgres/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/postgres/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/postgres/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/postgres/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/postgres/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/postgres/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/postgres/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/postgres/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/postgres/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/postgres/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/postgres/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/postgres/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/postgres/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/postgres/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/postgres/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/postgres/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/postgres/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/postgres/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/postgres/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/postgres/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/postgres/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/postgres/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/postgres/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/postgres/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/postgres/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/postgres/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/postgres/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/postgres/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/postgres/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/postgres/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/postgres/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/postgres/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/postgres/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/postgres/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/postgres/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/postgres/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/postgres/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/postgres/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/postgres/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/postgres/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/postgres/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/postgres/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/postgres/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/postgres/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/postgres/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/postgres/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/postgres/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/postgres/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/postgres/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/postgres/templates/library/base_v2_1_8/validations.py b/ix-dev/community/postgres/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/postgres/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/postgres/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/postgres/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/postgres/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/postgres/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/postgres/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/postgres/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/postgres/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/postgres/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/postgres/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/postgres/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/postgres/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/postgres/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/postgres/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/postgres/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/postgres/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/postgres/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/postgres/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/postgres/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/postgres/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/postgres/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/postgres/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/prowlarr/app.yaml b/ix-dev/community/prowlarr/app.yaml index 9e4972540d..8548c1943c 100644 --- a/ix-dev/community/prowlarr/app.yaml +++ b/ix-dev/community/prowlarr/app.yaml @@ -9,8 +9,8 @@ host_mounts: [] icon: https://media.sys.truenas.net/apps/prowlarr/icons/icon.png keywords: - indexer -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -29,4 +29,4 @@ sources: - https://prowlarr.com title: Prowlarr train: community -version: 1.3.14 +version: 1.3.15 diff --git a/ix-dev/community/prowlarr/templates/library/base_v2_1_8/container.py b/ix-dev/community/prowlarr/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/prowlarr/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/prowlarr/templates/library/base_v2_1_8/ports.py b/ix-dev/community/prowlarr/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/prowlarr/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/prowlarr/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/prowlarr/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/prowlarr/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/prowlarr/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/prowlarr/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/prowlarr/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/prowlarr/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/prowlarr/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/prowlarr/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/prowlarr/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/prowlarr/templates/library/base_v2_1_8/configs.py b/ix-dev/community/prowlarr/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/prowlarr/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/prowlarr/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/prowlarr/templates/library/base_v2_1_9/container.py b/ix-dev/community/prowlarr/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/prowlarr/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/prowlarr/templates/library/base_v2_1_8/depends.py b/ix-dev/community/prowlarr/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/prowlarr/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/prowlarr/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/prowlarr/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/prowlarr/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/prowlarr/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/prowlarr/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/prowlarr/templates/library/base_v2_1_8/deps.py b/ix-dev/community/prowlarr/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/prowlarr/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/prowlarr/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/prowlarr/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/prowlarr/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/prowlarr/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/prowlarr/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/prowlarr/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/prowlarr/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/prowlarr/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/prowlarr/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/prowlarr/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/prowlarr/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/prowlarr/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/prowlarr/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/prowlarr/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/prowlarr/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/prowlarr/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/prowlarr/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/prowlarr/templates/library/base_v2_1_8/device.py b/ix-dev/community/prowlarr/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/prowlarr/templates/library/base_v2_1_8/device.py rename to ix-dev/community/prowlarr/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/prowlarr/templates/library/base_v2_1_8/devices.py b/ix-dev/community/prowlarr/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/prowlarr/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/prowlarr/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/prowlarr/templates/library/base_v2_1_8/dns.py b/ix-dev/community/prowlarr/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/prowlarr/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/prowlarr/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/prowlarr/templates/library/base_v2_1_8/environment.py b/ix-dev/community/prowlarr/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/prowlarr/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/prowlarr/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/prowlarr/templates/library/base_v2_1_8/error.py b/ix-dev/community/prowlarr/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/prowlarr/templates/library/base_v2_1_8/error.py rename to ix-dev/community/prowlarr/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/prowlarr/templates/library/base_v2_1_8/expose.py b/ix-dev/community/prowlarr/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/prowlarr/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/prowlarr/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/prowlarr/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/prowlarr/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/prowlarr/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/prowlarr/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/prowlarr/templates/library/base_v2_1_8/functions.py b/ix-dev/community/prowlarr/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/prowlarr/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/prowlarr/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/prowlarr/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/prowlarr/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/prowlarr/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/prowlarr/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/prowlarr/templates/library/base_v2_1_8/labels.py b/ix-dev/community/prowlarr/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/prowlarr/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/prowlarr/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/prowlarr/templates/library/base_v2_1_8/notes.py b/ix-dev/community/prowlarr/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/prowlarr/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/prowlarr/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/prowlarr/templates/library/base_v2_1_8/portal.py b/ix-dev/community/prowlarr/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/prowlarr/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/prowlarr/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/prowlarr/templates/library/base_v2_1_8/portals.py b/ix-dev/community/prowlarr/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/prowlarr/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/prowlarr/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/prowlarr/templates/library/base_v2_1_9/ports.py b/ix-dev/community/prowlarr/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/prowlarr/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/prowlarr/templates/library/base_v2_1_8/render.py b/ix-dev/community/prowlarr/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/prowlarr/templates/library/base_v2_1_8/render.py rename to ix-dev/community/prowlarr/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/prowlarr/templates/library/base_v2_1_8/resources.py b/ix-dev/community/prowlarr/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/prowlarr/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/prowlarr/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/prowlarr/templates/library/base_v2_1_8/restart.py b/ix-dev/community/prowlarr/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/prowlarr/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/prowlarr/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/prowlarr/templates/library/base_v2_1_8/storage.py b/ix-dev/community/prowlarr/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/prowlarr/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/prowlarr/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/prowlarr/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/prowlarr/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/prowlarr/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/prowlarr/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/prowlarr/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/prowlarr/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/prowlarr/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/prowlarr/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/prowlarr/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/prowlarr/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/prowlarr/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/prowlarr/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/prowlarr/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/prowlarr/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/prowlarr/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/prowlarr/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/prowlarr/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/prowlarr/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/prowlarr/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/prowlarr/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/prowlarr/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/prowlarr/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/prowlarr/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/prowlarr/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/prowlarr/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/prowlarr/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/prowlarr/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/prowlarr/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/prowlarr/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/prowlarr/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/prowlarr/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/prowlarr/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/prowlarr/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/prowlarr/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/prowlarr/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/prowlarr/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/prowlarr/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/prowlarr/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/prowlarr/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/prowlarr/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/prowlarr/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/prowlarr/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/prowlarr/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/prowlarr/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/prowlarr/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/prowlarr/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/prowlarr/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/prowlarr/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/prowlarr/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/prowlarr/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/prowlarr/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/prowlarr/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/prowlarr/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/prowlarr/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/prowlarr/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/prowlarr/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/prowlarr/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/prowlarr/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/prowlarr/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/prowlarr/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/prowlarr/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/prowlarr/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/prowlarr/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/prowlarr/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/prowlarr/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/prowlarr/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/prowlarr/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/prowlarr/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/prowlarr/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/prowlarr/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/prowlarr/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/prowlarr/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/prowlarr/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/prowlarr/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/prowlarr/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/prowlarr/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/prowlarr/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/prowlarr/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/prowlarr/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/prowlarr/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/prowlarr/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/prowlarr/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/prowlarr/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/prowlarr/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/prowlarr/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/prowlarr/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/prowlarr/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/prowlarr/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/prowlarr/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/prowlarr/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/prowlarr/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/prowlarr/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/prowlarr/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/prowlarr/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/prowlarr/templates/library/base_v2_1_8/validations.py b/ix-dev/community/prowlarr/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/prowlarr/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/prowlarr/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/prowlarr/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/prowlarr/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/prowlarr/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/prowlarr/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/prowlarr/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/prowlarr/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/prowlarr/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/prowlarr/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/prowlarr/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/prowlarr/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/prowlarr/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/prowlarr/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/prowlarr/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/prowlarr/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/prowlarr/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/prowlarr/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/prowlarr/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/prowlarr/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/prowlarr/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/prowlarr/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/qbittorrent/app.yaml b/ix-dev/community/qbittorrent/app.yaml index 9a7778d415..c71ea44b23 100644 --- a/ix-dev/community/qbittorrent/app.yaml +++ b/ix-dev/community/qbittorrent/app.yaml @@ -11,8 +11,8 @@ keywords: - media - torrent - download -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -31,4 +31,4 @@ sources: - https://www.qbittorrent.org/ title: qBittorrent train: community -version: 1.1.14 +version: 1.1.15 diff --git a/ix-dev/community/qbittorrent/templates/library/base_v2_1_8/container.py b/ix-dev/community/qbittorrent/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/qbittorrent/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/qbittorrent/templates/library/base_v2_1_8/ports.py b/ix-dev/community/qbittorrent/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/qbittorrent/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/qbittorrent/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/qbittorrent/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/qbittorrent/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/qbittorrent/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/qbittorrent/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/qbittorrent/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/qbittorrent/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/qbittorrent/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/qbittorrent/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/qbittorrent/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/qbittorrent/templates/library/base_v2_1_8/configs.py b/ix-dev/community/qbittorrent/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/qbittorrent/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/qbittorrent/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/qbittorrent/templates/library/base_v2_1_9/container.py b/ix-dev/community/qbittorrent/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/qbittorrent/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/qbittorrent/templates/library/base_v2_1_8/depends.py b/ix-dev/community/qbittorrent/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/qbittorrent/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/qbittorrent/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/qbittorrent/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/qbittorrent/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/qbittorrent/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/qbittorrent/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/qbittorrent/templates/library/base_v2_1_8/deps.py b/ix-dev/community/qbittorrent/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/qbittorrent/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/qbittorrent/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/qbittorrent/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/qbittorrent/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/qbittorrent/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/qbittorrent/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/qbittorrent/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/qbittorrent/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/qbittorrent/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/qbittorrent/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/qbittorrent/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/qbittorrent/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/qbittorrent/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/qbittorrent/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/qbittorrent/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/qbittorrent/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/qbittorrent/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/qbittorrent/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/qbittorrent/templates/library/base_v2_1_8/device.py b/ix-dev/community/qbittorrent/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/qbittorrent/templates/library/base_v2_1_8/device.py rename to ix-dev/community/qbittorrent/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/qbittorrent/templates/library/base_v2_1_8/devices.py b/ix-dev/community/qbittorrent/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/qbittorrent/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/qbittorrent/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/qbittorrent/templates/library/base_v2_1_8/dns.py b/ix-dev/community/qbittorrent/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/qbittorrent/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/qbittorrent/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/qbittorrent/templates/library/base_v2_1_8/environment.py b/ix-dev/community/qbittorrent/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/qbittorrent/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/qbittorrent/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/qbittorrent/templates/library/base_v2_1_8/error.py b/ix-dev/community/qbittorrent/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/qbittorrent/templates/library/base_v2_1_8/error.py rename to ix-dev/community/qbittorrent/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/qbittorrent/templates/library/base_v2_1_8/expose.py b/ix-dev/community/qbittorrent/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/qbittorrent/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/qbittorrent/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/qbittorrent/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/qbittorrent/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/qbittorrent/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/qbittorrent/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/qbittorrent/templates/library/base_v2_1_8/functions.py b/ix-dev/community/qbittorrent/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/qbittorrent/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/qbittorrent/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/qbittorrent/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/qbittorrent/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/qbittorrent/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/qbittorrent/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/qbittorrent/templates/library/base_v2_1_8/labels.py b/ix-dev/community/qbittorrent/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/qbittorrent/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/qbittorrent/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/qbittorrent/templates/library/base_v2_1_8/notes.py b/ix-dev/community/qbittorrent/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/qbittorrent/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/qbittorrent/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/qbittorrent/templates/library/base_v2_1_8/portal.py b/ix-dev/community/qbittorrent/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/qbittorrent/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/qbittorrent/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/qbittorrent/templates/library/base_v2_1_8/portals.py b/ix-dev/community/qbittorrent/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/qbittorrent/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/qbittorrent/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/qbittorrent/templates/library/base_v2_1_9/ports.py b/ix-dev/community/qbittorrent/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/qbittorrent/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/qbittorrent/templates/library/base_v2_1_8/render.py b/ix-dev/community/qbittorrent/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/qbittorrent/templates/library/base_v2_1_8/render.py rename to ix-dev/community/qbittorrent/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/qbittorrent/templates/library/base_v2_1_8/resources.py b/ix-dev/community/qbittorrent/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/qbittorrent/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/qbittorrent/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/qbittorrent/templates/library/base_v2_1_8/restart.py b/ix-dev/community/qbittorrent/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/qbittorrent/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/qbittorrent/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/qbittorrent/templates/library/base_v2_1_8/storage.py b/ix-dev/community/qbittorrent/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/qbittorrent/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/qbittorrent/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/qbittorrent/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/qbittorrent/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/qbittorrent/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/qbittorrent/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/qbittorrent/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/qbittorrent/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/qbittorrent/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/qbittorrent/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/qbittorrent/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/qbittorrent/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/qbittorrent/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/qbittorrent/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/qbittorrent/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/qbittorrent/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/qbittorrent/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/qbittorrent/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/qbittorrent/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/qbittorrent/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/qbittorrent/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/qbittorrent/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/qbittorrent/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/qbittorrent/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/qbittorrent/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/qbittorrent/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/qbittorrent/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/qbittorrent/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/qbittorrent/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/qbittorrent/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/qbittorrent/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/qbittorrent/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/qbittorrent/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/qbittorrent/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/qbittorrent/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/qbittorrent/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/qbittorrent/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/qbittorrent/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/qbittorrent/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/qbittorrent/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/qbittorrent/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/qbittorrent/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/qbittorrent/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/qbittorrent/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/qbittorrent/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/qbittorrent/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/qbittorrent/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/qbittorrent/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/qbittorrent/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/qbittorrent/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/qbittorrent/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/qbittorrent/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/qbittorrent/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/qbittorrent/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/qbittorrent/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/qbittorrent/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/qbittorrent/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/qbittorrent/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/qbittorrent/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/qbittorrent/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/qbittorrent/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/qbittorrent/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/qbittorrent/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/qbittorrent/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/qbittorrent/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/qbittorrent/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/qbittorrent/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/qbittorrent/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/qbittorrent/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/qbittorrent/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/qbittorrent/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/qbittorrent/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/qbittorrent/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/qbittorrent/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/qbittorrent/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/qbittorrent/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/qbittorrent/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/qbittorrent/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/qbittorrent/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/qbittorrent/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/qbittorrent/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/qbittorrent/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/qbittorrent/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/qbittorrent/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/qbittorrent/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/qbittorrent/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/qbittorrent/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/qbittorrent/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/qbittorrent/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/qbittorrent/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/qbittorrent/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/qbittorrent/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/qbittorrent/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/qbittorrent/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/qbittorrent/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/qbittorrent/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/qbittorrent/templates/library/base_v2_1_8/validations.py b/ix-dev/community/qbittorrent/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/qbittorrent/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/qbittorrent/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/qbittorrent/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/qbittorrent/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/qbittorrent/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/qbittorrent/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/qbittorrent/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/qbittorrent/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/qbittorrent/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/qbittorrent/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/qbittorrent/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/qbittorrent/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/qbittorrent/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/qbittorrent/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/qbittorrent/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/qbittorrent/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/qbittorrent/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/qbittorrent/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/qbittorrent/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/qbittorrent/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/qbittorrent/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/qbittorrent/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/radarr/app.yaml b/ix-dev/community/radarr/app.yaml index df79b92a03..9abf318488 100644 --- a/ix-dev/community/radarr/app.yaml +++ b/ix-dev/community/radarr/app.yaml @@ -9,8 +9,8 @@ icon: https://media.sys.truenas.net/apps/radarr/icons/icon.png keywords: - media - movies -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -32,4 +32,4 @@ sources: - https://github.com/Radarr/Radarr title: Radarr train: community -version: 1.2.9 +version: 1.2.10 diff --git a/ix-dev/community/radarr/templates/library/base_v2_1_8/container.py b/ix-dev/community/radarr/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/radarr/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/radarr/templates/library/base_v2_1_8/ports.py b/ix-dev/community/radarr/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/radarr/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/radarr/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/radarr/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/radarr/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/radarr/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/radarr/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/radarr/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/radarr/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/radarr/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/radarr/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/radarr/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/radarr/templates/library/base_v2_1_8/configs.py b/ix-dev/community/radarr/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/radarr/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/radarr/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/radarr/templates/library/base_v2_1_9/container.py b/ix-dev/community/radarr/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/radarr/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/radarr/templates/library/base_v2_1_8/depends.py b/ix-dev/community/radarr/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/radarr/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/radarr/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/radarr/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/radarr/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/radarr/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/radarr/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/radarr/templates/library/base_v2_1_8/deps.py b/ix-dev/community/radarr/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/radarr/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/radarr/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/radarr/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/radarr/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/radarr/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/radarr/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/radarr/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/radarr/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/radarr/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/radarr/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/radarr/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/radarr/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/radarr/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/radarr/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/radarr/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/radarr/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/radarr/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/radarr/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/radarr/templates/library/base_v2_1_8/device.py b/ix-dev/community/radarr/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/radarr/templates/library/base_v2_1_8/device.py rename to ix-dev/community/radarr/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/radarr/templates/library/base_v2_1_8/devices.py b/ix-dev/community/radarr/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/radarr/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/radarr/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/radarr/templates/library/base_v2_1_8/dns.py b/ix-dev/community/radarr/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/radarr/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/radarr/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/radarr/templates/library/base_v2_1_8/environment.py b/ix-dev/community/radarr/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/radarr/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/radarr/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/radarr/templates/library/base_v2_1_8/error.py b/ix-dev/community/radarr/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/radarr/templates/library/base_v2_1_8/error.py rename to ix-dev/community/radarr/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/radarr/templates/library/base_v2_1_8/expose.py b/ix-dev/community/radarr/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/radarr/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/radarr/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/radarr/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/radarr/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/radarr/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/radarr/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/radarr/templates/library/base_v2_1_8/functions.py b/ix-dev/community/radarr/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/radarr/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/radarr/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/radarr/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/radarr/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/radarr/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/radarr/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/radarr/templates/library/base_v2_1_8/labels.py b/ix-dev/community/radarr/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/radarr/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/radarr/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/radarr/templates/library/base_v2_1_8/notes.py b/ix-dev/community/radarr/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/radarr/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/radarr/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/radarr/templates/library/base_v2_1_8/portal.py b/ix-dev/community/radarr/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/radarr/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/radarr/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/radarr/templates/library/base_v2_1_8/portals.py b/ix-dev/community/radarr/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/radarr/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/radarr/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/radarr/templates/library/base_v2_1_9/ports.py b/ix-dev/community/radarr/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/radarr/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/radarr/templates/library/base_v2_1_8/render.py b/ix-dev/community/radarr/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/radarr/templates/library/base_v2_1_8/render.py rename to ix-dev/community/radarr/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/radarr/templates/library/base_v2_1_8/resources.py b/ix-dev/community/radarr/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/radarr/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/radarr/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/radarr/templates/library/base_v2_1_8/restart.py b/ix-dev/community/radarr/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/radarr/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/radarr/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/radarr/templates/library/base_v2_1_8/storage.py b/ix-dev/community/radarr/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/radarr/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/radarr/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/radarr/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/radarr/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/radarr/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/radarr/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/radarr/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/radarr/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/radarr/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/radarr/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/radarr/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/radarr/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/radarr/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/radarr/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/radarr/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/radarr/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/radarr/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/radarr/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/radarr/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/radarr/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/radarr/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/radarr/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/radarr/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/radarr/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/radarr/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/radarr/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/radarr/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/radarr/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/radarr/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/radarr/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/radarr/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/radarr/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/radarr/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/radarr/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/radarr/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/radarr/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/radarr/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/radarr/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/radarr/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/radarr/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/radarr/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/radarr/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/radarr/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/radarr/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/radarr/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/radarr/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/radarr/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/radarr/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/radarr/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/radarr/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/radarr/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/radarr/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/radarr/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/radarr/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/radarr/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/radarr/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/radarr/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/radarr/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/radarr/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/radarr/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/radarr/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/radarr/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/radarr/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/radarr/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/radarr/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/radarr/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/radarr/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/radarr/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/radarr/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/radarr/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/radarr/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/radarr/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/radarr/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/radarr/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/radarr/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/radarr/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/radarr/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/radarr/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/radarr/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/radarr/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/radarr/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/radarr/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/radarr/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/radarr/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/radarr/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/radarr/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/radarr/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/radarr/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/radarr/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/radarr/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/radarr/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/radarr/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/radarr/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/radarr/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/radarr/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/radarr/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/radarr/templates/library/base_v2_1_8/validations.py b/ix-dev/community/radarr/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/radarr/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/radarr/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/radarr/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/radarr/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/radarr/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/radarr/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/radarr/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/radarr/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/radarr/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/radarr/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/radarr/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/radarr/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/radarr/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/radarr/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/radarr/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/radarr/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/radarr/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/radarr/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/radarr/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/radarr/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/radarr/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/radarr/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/readarr/app.yaml b/ix-dev/community/readarr/app.yaml index ec97911eca..c42c922e45 100644 --- a/ix-dev/community/readarr/app.yaml +++ b/ix-dev/community/readarr/app.yaml @@ -11,8 +11,8 @@ keywords: - media - ebook - audiobook -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -33,4 +33,4 @@ sources: - https://github.com/Readarr/Readarr title: Readarr train: community -version: 1.1.11 +version: 1.1.12 diff --git a/ix-dev/community/readarr/templates/library/base_v2_1_8/container.py b/ix-dev/community/readarr/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/readarr/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/readarr/templates/library/base_v2_1_8/ports.py b/ix-dev/community/readarr/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/readarr/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/readarr/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/readarr/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/readarr/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/readarr/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/readarr/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/readarr/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/readarr/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/readarr/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/readarr/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/readarr/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/readarr/templates/library/base_v2_1_8/configs.py b/ix-dev/community/readarr/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/readarr/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/readarr/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/readarr/templates/library/base_v2_1_9/container.py b/ix-dev/community/readarr/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/readarr/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/readarr/templates/library/base_v2_1_8/depends.py b/ix-dev/community/readarr/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/readarr/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/readarr/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/readarr/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/readarr/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/readarr/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/readarr/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/readarr/templates/library/base_v2_1_8/deps.py b/ix-dev/community/readarr/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/readarr/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/readarr/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/readarr/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/readarr/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/readarr/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/readarr/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/readarr/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/readarr/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/readarr/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/readarr/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/readarr/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/readarr/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/readarr/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/readarr/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/readarr/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/readarr/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/readarr/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/readarr/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/readarr/templates/library/base_v2_1_8/device.py b/ix-dev/community/readarr/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/readarr/templates/library/base_v2_1_8/device.py rename to ix-dev/community/readarr/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/readarr/templates/library/base_v2_1_8/devices.py b/ix-dev/community/readarr/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/readarr/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/readarr/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/readarr/templates/library/base_v2_1_8/dns.py b/ix-dev/community/readarr/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/readarr/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/readarr/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/readarr/templates/library/base_v2_1_8/environment.py b/ix-dev/community/readarr/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/readarr/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/readarr/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/readarr/templates/library/base_v2_1_8/error.py b/ix-dev/community/readarr/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/readarr/templates/library/base_v2_1_8/error.py rename to ix-dev/community/readarr/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/readarr/templates/library/base_v2_1_8/expose.py b/ix-dev/community/readarr/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/readarr/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/readarr/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/readarr/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/readarr/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/readarr/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/readarr/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/readarr/templates/library/base_v2_1_8/functions.py b/ix-dev/community/readarr/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/readarr/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/readarr/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/readarr/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/readarr/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/readarr/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/readarr/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/readarr/templates/library/base_v2_1_8/labels.py b/ix-dev/community/readarr/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/readarr/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/readarr/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/readarr/templates/library/base_v2_1_8/notes.py b/ix-dev/community/readarr/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/readarr/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/readarr/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/readarr/templates/library/base_v2_1_8/portal.py b/ix-dev/community/readarr/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/readarr/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/readarr/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/readarr/templates/library/base_v2_1_8/portals.py b/ix-dev/community/readarr/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/readarr/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/readarr/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/readarr/templates/library/base_v2_1_9/ports.py b/ix-dev/community/readarr/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/readarr/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/readarr/templates/library/base_v2_1_8/render.py b/ix-dev/community/readarr/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/readarr/templates/library/base_v2_1_8/render.py rename to ix-dev/community/readarr/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/readarr/templates/library/base_v2_1_8/resources.py b/ix-dev/community/readarr/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/readarr/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/readarr/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/readarr/templates/library/base_v2_1_8/restart.py b/ix-dev/community/readarr/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/readarr/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/readarr/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/readarr/templates/library/base_v2_1_8/storage.py b/ix-dev/community/readarr/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/readarr/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/readarr/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/readarr/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/readarr/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/readarr/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/readarr/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/readarr/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/readarr/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/readarr/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/readarr/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/readarr/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/readarr/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/readarr/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/readarr/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/readarr/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/readarr/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/readarr/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/readarr/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/readarr/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/readarr/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/readarr/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/readarr/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/readarr/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/readarr/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/readarr/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/readarr/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/readarr/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/readarr/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/readarr/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/readarr/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/readarr/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/readarr/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/readarr/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/readarr/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/readarr/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/readarr/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/readarr/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/readarr/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/readarr/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/readarr/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/readarr/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/readarr/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/readarr/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/readarr/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/readarr/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/readarr/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/readarr/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/readarr/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/readarr/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/readarr/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/readarr/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/readarr/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/readarr/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/readarr/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/readarr/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/readarr/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/readarr/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/readarr/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/readarr/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/readarr/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/readarr/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/readarr/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/readarr/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/readarr/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/readarr/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/readarr/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/readarr/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/readarr/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/readarr/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/readarr/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/readarr/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/readarr/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/readarr/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/readarr/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/readarr/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/readarr/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/readarr/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/readarr/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/readarr/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/readarr/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/readarr/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/readarr/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/readarr/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/readarr/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/readarr/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/readarr/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/readarr/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/readarr/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/readarr/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/readarr/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/readarr/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/readarr/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/readarr/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/readarr/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/readarr/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/readarr/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/readarr/templates/library/base_v2_1_8/validations.py b/ix-dev/community/readarr/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/readarr/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/readarr/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/readarr/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/readarr/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/readarr/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/readarr/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/readarr/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/readarr/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/readarr/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/readarr/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/readarr/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/readarr/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/readarr/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/readarr/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/readarr/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/readarr/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/readarr/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/readarr/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/readarr/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/readarr/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/readarr/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/readarr/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/recyclarr/app.yaml b/ix-dev/community/recyclarr/app.yaml index 545ec5bccd..7fe93c495c 100644 --- a/ix-dev/community/recyclarr/app.yaml +++ b/ix-dev/community/recyclarr/app.yaml @@ -11,8 +11,8 @@ keywords: - sync - sonarr - radarr -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -30,4 +30,4 @@ sources: - https://github.com/recyclarr/recyclarr/tree/recyclarr title: Recyclarr train: community -version: 1.1.5 +version: 1.1.6 diff --git a/ix-dev/community/recyclarr/templates/library/base_v2_1_8/container.py b/ix-dev/community/recyclarr/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/recyclarr/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/recyclarr/templates/library/base_v2_1_8/ports.py b/ix-dev/community/recyclarr/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/recyclarr/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/recyclarr/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/recyclarr/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/recyclarr/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/recyclarr/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/recyclarr/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/recyclarr/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/recyclarr/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/recyclarr/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/recyclarr/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/recyclarr/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/recyclarr/templates/library/base_v2_1_8/configs.py b/ix-dev/community/recyclarr/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/recyclarr/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/recyclarr/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/recyclarr/templates/library/base_v2_1_9/container.py b/ix-dev/community/recyclarr/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/recyclarr/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/recyclarr/templates/library/base_v2_1_8/depends.py b/ix-dev/community/recyclarr/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/recyclarr/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/recyclarr/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/recyclarr/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/recyclarr/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/recyclarr/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/recyclarr/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/recyclarr/templates/library/base_v2_1_8/deps.py b/ix-dev/community/recyclarr/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/recyclarr/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/recyclarr/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/recyclarr/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/recyclarr/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/recyclarr/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/recyclarr/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/recyclarr/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/recyclarr/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/recyclarr/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/recyclarr/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/recyclarr/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/recyclarr/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/recyclarr/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/recyclarr/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/recyclarr/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/recyclarr/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/recyclarr/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/recyclarr/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/recyclarr/templates/library/base_v2_1_8/device.py b/ix-dev/community/recyclarr/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/recyclarr/templates/library/base_v2_1_8/device.py rename to ix-dev/community/recyclarr/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/recyclarr/templates/library/base_v2_1_8/devices.py b/ix-dev/community/recyclarr/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/recyclarr/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/recyclarr/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/recyclarr/templates/library/base_v2_1_8/dns.py b/ix-dev/community/recyclarr/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/recyclarr/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/recyclarr/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/recyclarr/templates/library/base_v2_1_8/environment.py b/ix-dev/community/recyclarr/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/recyclarr/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/recyclarr/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/recyclarr/templates/library/base_v2_1_8/error.py b/ix-dev/community/recyclarr/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/recyclarr/templates/library/base_v2_1_8/error.py rename to ix-dev/community/recyclarr/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/recyclarr/templates/library/base_v2_1_8/expose.py b/ix-dev/community/recyclarr/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/recyclarr/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/recyclarr/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/recyclarr/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/recyclarr/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/recyclarr/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/recyclarr/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/recyclarr/templates/library/base_v2_1_8/functions.py b/ix-dev/community/recyclarr/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/recyclarr/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/recyclarr/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/recyclarr/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/recyclarr/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/recyclarr/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/recyclarr/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/recyclarr/templates/library/base_v2_1_8/labels.py b/ix-dev/community/recyclarr/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/recyclarr/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/recyclarr/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/recyclarr/templates/library/base_v2_1_8/notes.py b/ix-dev/community/recyclarr/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/recyclarr/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/recyclarr/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/recyclarr/templates/library/base_v2_1_8/portal.py b/ix-dev/community/recyclarr/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/recyclarr/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/recyclarr/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/recyclarr/templates/library/base_v2_1_8/portals.py b/ix-dev/community/recyclarr/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/recyclarr/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/recyclarr/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/recyclarr/templates/library/base_v2_1_9/ports.py b/ix-dev/community/recyclarr/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/recyclarr/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/recyclarr/templates/library/base_v2_1_8/render.py b/ix-dev/community/recyclarr/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/recyclarr/templates/library/base_v2_1_8/render.py rename to ix-dev/community/recyclarr/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/recyclarr/templates/library/base_v2_1_8/resources.py b/ix-dev/community/recyclarr/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/recyclarr/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/recyclarr/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/recyclarr/templates/library/base_v2_1_8/restart.py b/ix-dev/community/recyclarr/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/recyclarr/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/recyclarr/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/recyclarr/templates/library/base_v2_1_8/storage.py b/ix-dev/community/recyclarr/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/recyclarr/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/recyclarr/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/recyclarr/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/recyclarr/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/recyclarr/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/recyclarr/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/recyclarr/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/recyclarr/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/recyclarr/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/recyclarr/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/recyclarr/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/recyclarr/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/recyclarr/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/recyclarr/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/recyclarr/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/recyclarr/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/recyclarr/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/recyclarr/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/recyclarr/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/recyclarr/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/recyclarr/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/recyclarr/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/recyclarr/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/recyclarr/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/recyclarr/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/recyclarr/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/recyclarr/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/recyclarr/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/recyclarr/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/recyclarr/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/recyclarr/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/recyclarr/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/recyclarr/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/recyclarr/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/recyclarr/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/recyclarr/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/recyclarr/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/recyclarr/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/recyclarr/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/recyclarr/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/recyclarr/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/recyclarr/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/recyclarr/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/recyclarr/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/recyclarr/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/recyclarr/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/recyclarr/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/recyclarr/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/recyclarr/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/recyclarr/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/recyclarr/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/recyclarr/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/recyclarr/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/recyclarr/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/recyclarr/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/recyclarr/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/recyclarr/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/recyclarr/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/recyclarr/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/recyclarr/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/recyclarr/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/recyclarr/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/recyclarr/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/recyclarr/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/recyclarr/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/recyclarr/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/recyclarr/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/recyclarr/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/recyclarr/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/recyclarr/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/recyclarr/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/recyclarr/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/recyclarr/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/recyclarr/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/recyclarr/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/recyclarr/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/recyclarr/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/recyclarr/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/recyclarr/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/recyclarr/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/recyclarr/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/recyclarr/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/recyclarr/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/recyclarr/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/recyclarr/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/recyclarr/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/recyclarr/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/recyclarr/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/recyclarr/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/recyclarr/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/recyclarr/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/recyclarr/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/recyclarr/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/recyclarr/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/recyclarr/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/recyclarr/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/recyclarr/templates/library/base_v2_1_8/validations.py b/ix-dev/community/recyclarr/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/recyclarr/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/recyclarr/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/recyclarr/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/recyclarr/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/recyclarr/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/recyclarr/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/recyclarr/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/recyclarr/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/recyclarr/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/recyclarr/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/recyclarr/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/recyclarr/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/recyclarr/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/recyclarr/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/recyclarr/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/recyclarr/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/recyclarr/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/recyclarr/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/recyclarr/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/recyclarr/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/recyclarr/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/recyclarr/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/redis/app.yaml b/ix-dev/community/redis/app.yaml index d6f406a3bb..e36d7eecff 100644 --- a/ix-dev/community/redis/app.yaml +++ b/ix-dev/community/redis/app.yaml @@ -9,8 +9,8 @@ host_mounts: [] icon: https://media.sys.truenas.net/apps/redis/icons/icon.png keywords: - cache -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -29,4 +29,4 @@ sources: - https://redis.io/ title: Redis train: community -version: 1.1.7 +version: 1.1.8 diff --git a/ix-dev/community/redis/templates/library/base_v2_1_8/container.py b/ix-dev/community/redis/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/redis/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/redis/templates/library/base_v2_1_8/ports.py b/ix-dev/community/redis/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/redis/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/redis/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/redis/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/redis/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/redis/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/redis/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/redis/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/redis/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/redis/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/redis/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/redis/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/redis/templates/library/base_v2_1_8/configs.py b/ix-dev/community/redis/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/redis/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/redis/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/redis/templates/library/base_v2_1_9/container.py b/ix-dev/community/redis/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/redis/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/redis/templates/library/base_v2_1_8/depends.py b/ix-dev/community/redis/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/redis/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/redis/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/redis/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/redis/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/redis/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/redis/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/redis/templates/library/base_v2_1_8/deps.py b/ix-dev/community/redis/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/redis/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/redis/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/redis/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/redis/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/redis/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/redis/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/redis/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/redis/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/redis/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/redis/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/redis/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/redis/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/redis/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/redis/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/redis/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/redis/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/redis/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/redis/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/redis/templates/library/base_v2_1_8/device.py b/ix-dev/community/redis/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/redis/templates/library/base_v2_1_8/device.py rename to ix-dev/community/redis/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/redis/templates/library/base_v2_1_8/devices.py b/ix-dev/community/redis/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/redis/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/redis/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/redis/templates/library/base_v2_1_8/dns.py b/ix-dev/community/redis/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/redis/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/redis/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/redis/templates/library/base_v2_1_8/environment.py b/ix-dev/community/redis/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/redis/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/redis/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/redis/templates/library/base_v2_1_8/error.py b/ix-dev/community/redis/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/redis/templates/library/base_v2_1_8/error.py rename to ix-dev/community/redis/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/redis/templates/library/base_v2_1_8/expose.py b/ix-dev/community/redis/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/redis/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/redis/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/redis/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/redis/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/redis/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/redis/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/redis/templates/library/base_v2_1_8/functions.py b/ix-dev/community/redis/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/redis/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/redis/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/redis/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/redis/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/redis/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/redis/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/redis/templates/library/base_v2_1_8/labels.py b/ix-dev/community/redis/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/redis/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/redis/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/redis/templates/library/base_v2_1_8/notes.py b/ix-dev/community/redis/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/redis/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/redis/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/redis/templates/library/base_v2_1_8/portal.py b/ix-dev/community/redis/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/redis/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/redis/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/redis/templates/library/base_v2_1_8/portals.py b/ix-dev/community/redis/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/redis/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/redis/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/redis/templates/library/base_v2_1_9/ports.py b/ix-dev/community/redis/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/redis/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/redis/templates/library/base_v2_1_8/render.py b/ix-dev/community/redis/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/redis/templates/library/base_v2_1_8/render.py rename to ix-dev/community/redis/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/redis/templates/library/base_v2_1_8/resources.py b/ix-dev/community/redis/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/redis/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/redis/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/redis/templates/library/base_v2_1_8/restart.py b/ix-dev/community/redis/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/redis/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/redis/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/redis/templates/library/base_v2_1_8/storage.py b/ix-dev/community/redis/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/redis/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/redis/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/redis/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/redis/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/redis/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/redis/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/redis/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/redis/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/redis/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/redis/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/redis/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/redis/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/redis/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/redis/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/redis/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/redis/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/redis/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/redis/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/redis/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/redis/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/redis/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/redis/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/redis/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/redis/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/redis/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/redis/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/redis/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/redis/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/redis/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/redis/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/redis/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/redis/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/redis/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/redis/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/redis/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/redis/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/redis/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/redis/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/redis/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/redis/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/redis/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/redis/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/redis/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/redis/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/redis/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/redis/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/redis/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/redis/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/redis/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/redis/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/redis/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/redis/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/redis/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/redis/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/redis/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/redis/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/redis/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/redis/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/redis/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/redis/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/redis/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/redis/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/redis/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/redis/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/redis/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/redis/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/redis/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/redis/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/redis/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/redis/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/redis/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/redis/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/redis/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/redis/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/redis/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/redis/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/redis/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/redis/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/redis/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/redis/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/redis/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/redis/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/redis/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/redis/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/redis/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/redis/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/redis/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/redis/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/redis/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/redis/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/redis/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/redis/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/redis/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/redis/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/redis/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/redis/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/redis/templates/library/base_v2_1_8/validations.py b/ix-dev/community/redis/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/redis/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/redis/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/redis/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/redis/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/redis/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/redis/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/redis/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/redis/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/redis/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/redis/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/redis/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/redis/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/redis/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/redis/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/redis/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/redis/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/redis/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/redis/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/redis/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/redis/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/redis/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/redis/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/roundcube/app.yaml b/ix-dev/community/roundcube/app.yaml index e634ee10c4..9513acf964 100644 --- a/ix-dev/community/roundcube/app.yaml +++ b/ix-dev/community/roundcube/app.yaml @@ -20,8 +20,8 @@ icon: https://media.sys.truenas.net/apps/roundcube/icons/icon.png keywords: - webmail - email -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -49,4 +49,4 @@ sources: - https://hub.docker.com/r/roundcube/roundcubemail/ title: Roundcube train: community -version: 1.2.4 +version: 1.2.5 diff --git a/ix-dev/community/roundcube/templates/library/base_v2_1_8/container.py b/ix-dev/community/roundcube/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/roundcube/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/roundcube/templates/library/base_v2_1_8/ports.py b/ix-dev/community/roundcube/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/roundcube/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/roundcube/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/roundcube/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/roundcube/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/roundcube/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/roundcube/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/roundcube/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/roundcube/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/roundcube/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/roundcube/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/roundcube/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/roundcube/templates/library/base_v2_1_8/configs.py b/ix-dev/community/roundcube/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/roundcube/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/roundcube/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/roundcube/templates/library/base_v2_1_9/container.py b/ix-dev/community/roundcube/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/roundcube/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/roundcube/templates/library/base_v2_1_8/depends.py b/ix-dev/community/roundcube/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/roundcube/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/roundcube/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/roundcube/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/roundcube/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/roundcube/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/roundcube/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/roundcube/templates/library/base_v2_1_8/deps.py b/ix-dev/community/roundcube/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/roundcube/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/roundcube/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/roundcube/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/roundcube/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/roundcube/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/roundcube/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/roundcube/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/roundcube/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/roundcube/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/roundcube/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/roundcube/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/roundcube/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/roundcube/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/roundcube/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/roundcube/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/roundcube/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/roundcube/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/roundcube/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/roundcube/templates/library/base_v2_1_8/device.py b/ix-dev/community/roundcube/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/roundcube/templates/library/base_v2_1_8/device.py rename to ix-dev/community/roundcube/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/roundcube/templates/library/base_v2_1_8/devices.py b/ix-dev/community/roundcube/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/roundcube/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/roundcube/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/roundcube/templates/library/base_v2_1_8/dns.py b/ix-dev/community/roundcube/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/roundcube/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/roundcube/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/roundcube/templates/library/base_v2_1_8/environment.py b/ix-dev/community/roundcube/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/roundcube/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/roundcube/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/roundcube/templates/library/base_v2_1_8/error.py b/ix-dev/community/roundcube/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/roundcube/templates/library/base_v2_1_8/error.py rename to ix-dev/community/roundcube/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/roundcube/templates/library/base_v2_1_8/expose.py b/ix-dev/community/roundcube/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/roundcube/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/roundcube/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/roundcube/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/roundcube/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/roundcube/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/roundcube/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/roundcube/templates/library/base_v2_1_8/functions.py b/ix-dev/community/roundcube/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/roundcube/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/roundcube/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/roundcube/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/roundcube/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/roundcube/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/roundcube/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/roundcube/templates/library/base_v2_1_8/labels.py b/ix-dev/community/roundcube/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/roundcube/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/roundcube/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/roundcube/templates/library/base_v2_1_8/notes.py b/ix-dev/community/roundcube/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/roundcube/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/roundcube/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/roundcube/templates/library/base_v2_1_8/portal.py b/ix-dev/community/roundcube/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/roundcube/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/roundcube/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/roundcube/templates/library/base_v2_1_8/portals.py b/ix-dev/community/roundcube/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/roundcube/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/roundcube/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/roundcube/templates/library/base_v2_1_9/ports.py b/ix-dev/community/roundcube/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/roundcube/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/roundcube/templates/library/base_v2_1_8/render.py b/ix-dev/community/roundcube/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/roundcube/templates/library/base_v2_1_8/render.py rename to ix-dev/community/roundcube/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/roundcube/templates/library/base_v2_1_8/resources.py b/ix-dev/community/roundcube/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/roundcube/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/roundcube/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/roundcube/templates/library/base_v2_1_8/restart.py b/ix-dev/community/roundcube/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/roundcube/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/roundcube/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/roundcube/templates/library/base_v2_1_8/storage.py b/ix-dev/community/roundcube/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/roundcube/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/roundcube/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/roundcube/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/roundcube/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/roundcube/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/roundcube/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/roundcube/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/roundcube/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/roundcube/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/roundcube/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/roundcube/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/roundcube/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/roundcube/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/roundcube/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/roundcube/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/roundcube/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/roundcube/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/roundcube/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/roundcube/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/roundcube/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/roundcube/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/roundcube/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/roundcube/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/roundcube/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/roundcube/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/roundcube/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/roundcube/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/roundcube/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/roundcube/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/roundcube/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/roundcube/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/roundcube/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/roundcube/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/roundcube/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/roundcube/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/roundcube/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/roundcube/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/roundcube/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/roundcube/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/roundcube/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/roundcube/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/roundcube/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/roundcube/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/roundcube/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/roundcube/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/roundcube/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/roundcube/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/roundcube/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/roundcube/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/roundcube/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/roundcube/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/roundcube/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/roundcube/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/roundcube/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/roundcube/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/roundcube/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/roundcube/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/roundcube/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/roundcube/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/roundcube/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/roundcube/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/roundcube/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/roundcube/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/roundcube/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/roundcube/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/roundcube/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/roundcube/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/roundcube/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/roundcube/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/roundcube/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/roundcube/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/roundcube/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/roundcube/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/roundcube/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/roundcube/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/roundcube/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/roundcube/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/roundcube/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/roundcube/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/roundcube/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/roundcube/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/roundcube/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/roundcube/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/roundcube/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/roundcube/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/roundcube/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/roundcube/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/roundcube/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/roundcube/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/roundcube/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/roundcube/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/roundcube/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/roundcube/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/roundcube/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/roundcube/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/roundcube/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/roundcube/templates/library/base_v2_1_8/validations.py b/ix-dev/community/roundcube/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/roundcube/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/roundcube/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/roundcube/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/roundcube/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/roundcube/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/roundcube/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/roundcube/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/roundcube/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/roundcube/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/roundcube/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/roundcube/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/roundcube/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/roundcube/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/roundcube/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/roundcube/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/roundcube/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/roundcube/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/roundcube/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/roundcube/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/roundcube/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/roundcube/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/roundcube/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/rsyncd/app.yaml b/ix-dev/community/rsyncd/app.yaml index 92dd0bd7fc..651947a93f 100644 --- a/ix-dev/community/rsyncd/app.yaml +++ b/ix-dev/community/rsyncd/app.yaml @@ -22,8 +22,8 @@ keywords: - sync - rsync - file transfer -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -41,4 +41,4 @@ sources: - https://hub.docker.com/r/ixsystems/rsyncd title: Rsync Daemon train: community -version: 1.1.5 +version: 1.1.6 diff --git a/ix-dev/community/rsyncd/templates/library/base_v2_1_8/container.py b/ix-dev/community/rsyncd/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/rsyncd/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/rsyncd/templates/library/base_v2_1_8/ports.py b/ix-dev/community/rsyncd/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/rsyncd/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/rsyncd/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/rsyncd/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/rsyncd/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/rsyncd/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/rsyncd/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/rsyncd/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/rsyncd/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/rsyncd/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/rsyncd/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/rsyncd/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/rsyncd/templates/library/base_v2_1_8/configs.py b/ix-dev/community/rsyncd/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/rsyncd/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/rsyncd/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/rsyncd/templates/library/base_v2_1_9/container.py b/ix-dev/community/rsyncd/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/rsyncd/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/rsyncd/templates/library/base_v2_1_8/depends.py b/ix-dev/community/rsyncd/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/rsyncd/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/rsyncd/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/rsyncd/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/rsyncd/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/rsyncd/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/rsyncd/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/rsyncd/templates/library/base_v2_1_8/deps.py b/ix-dev/community/rsyncd/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/rsyncd/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/rsyncd/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/rsyncd/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/rsyncd/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/rsyncd/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/rsyncd/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/rsyncd/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/rsyncd/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/rsyncd/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/rsyncd/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/rsyncd/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/rsyncd/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/rsyncd/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/rsyncd/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/rsyncd/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/rsyncd/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/rsyncd/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/rsyncd/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/rsyncd/templates/library/base_v2_1_8/device.py b/ix-dev/community/rsyncd/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/rsyncd/templates/library/base_v2_1_8/device.py rename to ix-dev/community/rsyncd/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/rsyncd/templates/library/base_v2_1_8/devices.py b/ix-dev/community/rsyncd/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/rsyncd/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/rsyncd/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/rsyncd/templates/library/base_v2_1_8/dns.py b/ix-dev/community/rsyncd/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/rsyncd/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/rsyncd/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/rsyncd/templates/library/base_v2_1_8/environment.py b/ix-dev/community/rsyncd/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/rsyncd/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/rsyncd/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/rsyncd/templates/library/base_v2_1_8/error.py b/ix-dev/community/rsyncd/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/rsyncd/templates/library/base_v2_1_8/error.py rename to ix-dev/community/rsyncd/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/rsyncd/templates/library/base_v2_1_8/expose.py b/ix-dev/community/rsyncd/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/rsyncd/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/rsyncd/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/rsyncd/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/rsyncd/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/rsyncd/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/rsyncd/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/rsyncd/templates/library/base_v2_1_8/functions.py b/ix-dev/community/rsyncd/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/rsyncd/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/rsyncd/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/rsyncd/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/rsyncd/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/rsyncd/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/rsyncd/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/rsyncd/templates/library/base_v2_1_8/labels.py b/ix-dev/community/rsyncd/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/rsyncd/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/rsyncd/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/rsyncd/templates/library/base_v2_1_8/notes.py b/ix-dev/community/rsyncd/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/rsyncd/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/rsyncd/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/rsyncd/templates/library/base_v2_1_8/portal.py b/ix-dev/community/rsyncd/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/rsyncd/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/rsyncd/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/rsyncd/templates/library/base_v2_1_8/portals.py b/ix-dev/community/rsyncd/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/rsyncd/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/rsyncd/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/rsyncd/templates/library/base_v2_1_9/ports.py b/ix-dev/community/rsyncd/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/rsyncd/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/rsyncd/templates/library/base_v2_1_8/render.py b/ix-dev/community/rsyncd/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/rsyncd/templates/library/base_v2_1_8/render.py rename to ix-dev/community/rsyncd/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/rsyncd/templates/library/base_v2_1_8/resources.py b/ix-dev/community/rsyncd/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/rsyncd/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/rsyncd/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/rsyncd/templates/library/base_v2_1_8/restart.py b/ix-dev/community/rsyncd/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/rsyncd/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/rsyncd/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/rsyncd/templates/library/base_v2_1_8/storage.py b/ix-dev/community/rsyncd/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/rsyncd/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/rsyncd/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/rsyncd/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/rsyncd/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/rsyncd/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/rsyncd/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/rsyncd/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/rsyncd/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/rsyncd/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/rsyncd/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/rsyncd/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/rsyncd/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/rsyncd/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/rsyncd/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/rsyncd/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/rsyncd/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/rsyncd/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/rsyncd/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/rsyncd/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/rsyncd/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/rsyncd/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/rsyncd/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/rsyncd/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/rsyncd/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/rsyncd/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/rsyncd/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/rsyncd/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/rsyncd/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/rsyncd/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/rsyncd/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/rsyncd/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/rsyncd/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/rsyncd/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/rsyncd/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/rsyncd/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/rsyncd/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/rsyncd/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/rsyncd/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/rsyncd/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/rsyncd/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/rsyncd/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/rsyncd/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/rsyncd/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/rsyncd/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/rsyncd/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/rsyncd/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/rsyncd/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/rsyncd/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/rsyncd/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/rsyncd/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/rsyncd/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/rsyncd/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/rsyncd/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/rsyncd/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/rsyncd/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/rsyncd/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/rsyncd/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/rsyncd/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/rsyncd/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/rsyncd/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/rsyncd/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/rsyncd/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/rsyncd/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/rsyncd/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/rsyncd/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/rsyncd/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/rsyncd/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/rsyncd/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/rsyncd/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/rsyncd/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/rsyncd/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/rsyncd/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/rsyncd/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/rsyncd/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/rsyncd/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/rsyncd/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/rsyncd/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/rsyncd/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/rsyncd/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/rsyncd/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/rsyncd/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/rsyncd/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/rsyncd/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/rsyncd/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/rsyncd/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/rsyncd/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/rsyncd/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/rsyncd/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/rsyncd/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/rsyncd/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/rsyncd/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/rsyncd/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/rsyncd/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/rsyncd/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/rsyncd/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/rsyncd/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/rsyncd/templates/library/base_v2_1_8/validations.py b/ix-dev/community/rsyncd/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/rsyncd/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/rsyncd/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/rsyncd/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/rsyncd/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/rsyncd/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/rsyncd/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/rsyncd/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/rsyncd/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/rsyncd/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/rsyncd/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/rsyncd/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/rsyncd/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/rsyncd/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/rsyncd/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/rsyncd/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/rsyncd/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/rsyncd/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/rsyncd/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/rsyncd/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/rsyncd/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/rsyncd/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/rsyncd/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/rust-desk/app.yaml b/ix-dev/community/rust-desk/app.yaml index 548431ac50..9de2266020 100644 --- a/ix-dev/community/rust-desk/app.yaml +++ b/ix-dev/community/rust-desk/app.yaml @@ -9,8 +9,8 @@ icon: https://media.sys.truenas.net/apps/rust-desk/icons/icon.png keywords: - remote - desktop -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -28,4 +28,4 @@ sources: - https://github.com/rustdesk/rustdesk-server title: Rust Desk train: community -version: 1.1.5 +version: 1.1.6 diff --git a/ix-dev/community/rust-desk/templates/library/base_v2_1_8/container.py b/ix-dev/community/rust-desk/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/rust-desk/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/rust-desk/templates/library/base_v2_1_8/ports.py b/ix-dev/community/rust-desk/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/rust-desk/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/rust-desk/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/rust-desk/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/rust-desk/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/rust-desk/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/rust-desk/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/rust-desk/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/rust-desk/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/rust-desk/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/rust-desk/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/rust-desk/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/rust-desk/templates/library/base_v2_1_8/configs.py b/ix-dev/community/rust-desk/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/rust-desk/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/rust-desk/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/rust-desk/templates/library/base_v2_1_9/container.py b/ix-dev/community/rust-desk/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/rust-desk/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/rust-desk/templates/library/base_v2_1_8/depends.py b/ix-dev/community/rust-desk/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/rust-desk/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/rust-desk/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/rust-desk/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/rust-desk/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/rust-desk/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/rust-desk/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/rust-desk/templates/library/base_v2_1_8/deps.py b/ix-dev/community/rust-desk/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/rust-desk/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/rust-desk/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/rust-desk/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/rust-desk/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/rust-desk/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/rust-desk/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/rust-desk/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/rust-desk/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/rust-desk/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/rust-desk/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/rust-desk/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/rust-desk/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/rust-desk/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/rust-desk/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/rust-desk/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/rust-desk/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/rust-desk/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/rust-desk/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/rust-desk/templates/library/base_v2_1_8/device.py b/ix-dev/community/rust-desk/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/rust-desk/templates/library/base_v2_1_8/device.py rename to ix-dev/community/rust-desk/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/rust-desk/templates/library/base_v2_1_8/devices.py b/ix-dev/community/rust-desk/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/rust-desk/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/rust-desk/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/rust-desk/templates/library/base_v2_1_8/dns.py b/ix-dev/community/rust-desk/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/rust-desk/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/rust-desk/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/rust-desk/templates/library/base_v2_1_8/environment.py b/ix-dev/community/rust-desk/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/rust-desk/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/rust-desk/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/rust-desk/templates/library/base_v2_1_8/error.py b/ix-dev/community/rust-desk/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/rust-desk/templates/library/base_v2_1_8/error.py rename to ix-dev/community/rust-desk/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/rust-desk/templates/library/base_v2_1_8/expose.py b/ix-dev/community/rust-desk/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/rust-desk/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/rust-desk/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/rust-desk/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/rust-desk/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/rust-desk/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/rust-desk/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/rust-desk/templates/library/base_v2_1_8/functions.py b/ix-dev/community/rust-desk/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/rust-desk/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/rust-desk/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/rust-desk/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/rust-desk/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/rust-desk/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/rust-desk/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/rust-desk/templates/library/base_v2_1_8/labels.py b/ix-dev/community/rust-desk/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/rust-desk/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/rust-desk/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/rust-desk/templates/library/base_v2_1_8/notes.py b/ix-dev/community/rust-desk/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/rust-desk/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/rust-desk/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/rust-desk/templates/library/base_v2_1_8/portal.py b/ix-dev/community/rust-desk/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/rust-desk/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/rust-desk/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/rust-desk/templates/library/base_v2_1_8/portals.py b/ix-dev/community/rust-desk/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/rust-desk/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/rust-desk/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/rust-desk/templates/library/base_v2_1_9/ports.py b/ix-dev/community/rust-desk/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/rust-desk/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/rust-desk/templates/library/base_v2_1_8/render.py b/ix-dev/community/rust-desk/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/rust-desk/templates/library/base_v2_1_8/render.py rename to ix-dev/community/rust-desk/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/rust-desk/templates/library/base_v2_1_8/resources.py b/ix-dev/community/rust-desk/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/rust-desk/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/rust-desk/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/rust-desk/templates/library/base_v2_1_8/restart.py b/ix-dev/community/rust-desk/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/rust-desk/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/rust-desk/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/rust-desk/templates/library/base_v2_1_8/storage.py b/ix-dev/community/rust-desk/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/rust-desk/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/rust-desk/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/rust-desk/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/rust-desk/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/rust-desk/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/rust-desk/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/rust-desk/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/rust-desk/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/rust-desk/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/rust-desk/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/rust-desk/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/rust-desk/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/rust-desk/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/rust-desk/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/rust-desk/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/rust-desk/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/rust-desk/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/rust-desk/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/rust-desk/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/rust-desk/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/rust-desk/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/rust-desk/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/rust-desk/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/rust-desk/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/rust-desk/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/rust-desk/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/rust-desk/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/rust-desk/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/rust-desk/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/rust-desk/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/rust-desk/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/rust-desk/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/rust-desk/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/rust-desk/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/rust-desk/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/rust-desk/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/rust-desk/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/rust-desk/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/rust-desk/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/rust-desk/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/rust-desk/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/rust-desk/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/rust-desk/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/rust-desk/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/rust-desk/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/rust-desk/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/rust-desk/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/rust-desk/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/rust-desk/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/rust-desk/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/rust-desk/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/rust-desk/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/rust-desk/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/rust-desk/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/rust-desk/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/rust-desk/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/rust-desk/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/rust-desk/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/rust-desk/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/rust-desk/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/rust-desk/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/rust-desk/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/rust-desk/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/rust-desk/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/rust-desk/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/rust-desk/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/rust-desk/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/rust-desk/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/rust-desk/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/rust-desk/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/rust-desk/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/rust-desk/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/rust-desk/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/rust-desk/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/rust-desk/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/rust-desk/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/rust-desk/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/rust-desk/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/rust-desk/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/rust-desk/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/rust-desk/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/rust-desk/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/rust-desk/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/rust-desk/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/rust-desk/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/rust-desk/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/rust-desk/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/rust-desk/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/rust-desk/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/rust-desk/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/rust-desk/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/rust-desk/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/rust-desk/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/rust-desk/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/rust-desk/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/rust-desk/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/rust-desk/templates/library/base_v2_1_8/validations.py b/ix-dev/community/rust-desk/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/rust-desk/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/rust-desk/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/rust-desk/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/rust-desk/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/rust-desk/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/rust-desk/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/rust-desk/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/rust-desk/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/rust-desk/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/rust-desk/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/rust-desk/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/rust-desk/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/rust-desk/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/rust-desk/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/rust-desk/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/rust-desk/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/rust-desk/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/rust-desk/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/rust-desk/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/rust-desk/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/rust-desk/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/rust-desk/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/sabnzbd/app.yaml b/ix-dev/community/sabnzbd/app.yaml index 56051c4ee9..01353e74b1 100644 --- a/ix-dev/community/sabnzbd/app.yaml +++ b/ix-dev/community/sabnzbd/app.yaml @@ -10,8 +10,8 @@ keywords: - media - usenet - newsreader -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -32,4 +32,4 @@ sources: - https://sabnzbd.org/ title: SABnzbd train: community -version: 1.1.7 +version: 1.1.8 diff --git a/ix-dev/community/sabnzbd/templates/library/base_v2_1_8/container.py b/ix-dev/community/sabnzbd/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/sabnzbd/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/sabnzbd/templates/library/base_v2_1_8/ports.py b/ix-dev/community/sabnzbd/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/sabnzbd/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/sabnzbd/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/sabnzbd/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/sabnzbd/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/sabnzbd/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/sabnzbd/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/sabnzbd/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/sabnzbd/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/sabnzbd/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/sabnzbd/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/sabnzbd/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/sabnzbd/templates/library/base_v2_1_8/configs.py b/ix-dev/community/sabnzbd/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/sabnzbd/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/sabnzbd/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/sabnzbd/templates/library/base_v2_1_9/container.py b/ix-dev/community/sabnzbd/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/sabnzbd/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/sabnzbd/templates/library/base_v2_1_8/depends.py b/ix-dev/community/sabnzbd/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/sabnzbd/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/sabnzbd/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/sabnzbd/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/sabnzbd/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/sabnzbd/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/sabnzbd/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/sabnzbd/templates/library/base_v2_1_8/deps.py b/ix-dev/community/sabnzbd/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/sabnzbd/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/sabnzbd/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/sabnzbd/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/sabnzbd/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/sabnzbd/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/sabnzbd/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/sabnzbd/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/sabnzbd/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/sabnzbd/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/sabnzbd/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/sabnzbd/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/sabnzbd/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/sabnzbd/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/sabnzbd/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/sabnzbd/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/sabnzbd/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/sabnzbd/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/sabnzbd/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/sabnzbd/templates/library/base_v2_1_8/device.py b/ix-dev/community/sabnzbd/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/sabnzbd/templates/library/base_v2_1_8/device.py rename to ix-dev/community/sabnzbd/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/sabnzbd/templates/library/base_v2_1_8/devices.py b/ix-dev/community/sabnzbd/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/sabnzbd/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/sabnzbd/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/sabnzbd/templates/library/base_v2_1_8/dns.py b/ix-dev/community/sabnzbd/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/sabnzbd/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/sabnzbd/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/sabnzbd/templates/library/base_v2_1_8/environment.py b/ix-dev/community/sabnzbd/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/sabnzbd/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/sabnzbd/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/sabnzbd/templates/library/base_v2_1_8/error.py b/ix-dev/community/sabnzbd/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/sabnzbd/templates/library/base_v2_1_8/error.py rename to ix-dev/community/sabnzbd/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/sabnzbd/templates/library/base_v2_1_8/expose.py b/ix-dev/community/sabnzbd/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/sabnzbd/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/sabnzbd/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/sabnzbd/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/sabnzbd/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/sabnzbd/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/sabnzbd/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/sabnzbd/templates/library/base_v2_1_8/functions.py b/ix-dev/community/sabnzbd/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/sabnzbd/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/sabnzbd/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/sabnzbd/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/sabnzbd/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/sabnzbd/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/sabnzbd/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/sabnzbd/templates/library/base_v2_1_8/labels.py b/ix-dev/community/sabnzbd/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/sabnzbd/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/sabnzbd/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/sabnzbd/templates/library/base_v2_1_8/notes.py b/ix-dev/community/sabnzbd/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/sabnzbd/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/sabnzbd/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/sabnzbd/templates/library/base_v2_1_8/portal.py b/ix-dev/community/sabnzbd/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/sabnzbd/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/sabnzbd/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/sabnzbd/templates/library/base_v2_1_8/portals.py b/ix-dev/community/sabnzbd/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/sabnzbd/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/sabnzbd/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/sabnzbd/templates/library/base_v2_1_9/ports.py b/ix-dev/community/sabnzbd/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/sabnzbd/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/sabnzbd/templates/library/base_v2_1_8/render.py b/ix-dev/community/sabnzbd/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/sabnzbd/templates/library/base_v2_1_8/render.py rename to ix-dev/community/sabnzbd/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/sabnzbd/templates/library/base_v2_1_8/resources.py b/ix-dev/community/sabnzbd/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/sabnzbd/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/sabnzbd/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/sabnzbd/templates/library/base_v2_1_8/restart.py b/ix-dev/community/sabnzbd/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/sabnzbd/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/sabnzbd/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/sabnzbd/templates/library/base_v2_1_8/storage.py b/ix-dev/community/sabnzbd/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/sabnzbd/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/sabnzbd/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/sabnzbd/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/sabnzbd/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/sabnzbd/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/sabnzbd/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/sabnzbd/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/sabnzbd/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/sabnzbd/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/sabnzbd/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/sabnzbd/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/sabnzbd/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/sabnzbd/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/sabnzbd/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/sabnzbd/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/sabnzbd/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/sabnzbd/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/sabnzbd/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/sabnzbd/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/sabnzbd/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/sabnzbd/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/sabnzbd/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/sabnzbd/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/sabnzbd/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/sabnzbd/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/sabnzbd/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/sabnzbd/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/sabnzbd/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/sabnzbd/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/sabnzbd/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/sabnzbd/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/sabnzbd/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/sabnzbd/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/sabnzbd/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/sabnzbd/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/sabnzbd/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/sabnzbd/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/sabnzbd/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/sabnzbd/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/sabnzbd/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/sabnzbd/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/sabnzbd/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/sabnzbd/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/sabnzbd/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/sabnzbd/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/sabnzbd/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/sabnzbd/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/sabnzbd/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/sabnzbd/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/sabnzbd/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/sabnzbd/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/sabnzbd/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/sabnzbd/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/sabnzbd/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/sabnzbd/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/sabnzbd/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/sabnzbd/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/sabnzbd/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/sabnzbd/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/sabnzbd/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/sabnzbd/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/sabnzbd/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/sabnzbd/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/sabnzbd/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/sabnzbd/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/sabnzbd/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/sabnzbd/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/sabnzbd/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/sabnzbd/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/sabnzbd/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/sabnzbd/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/sabnzbd/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/sabnzbd/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/sabnzbd/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/sabnzbd/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/sabnzbd/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/sabnzbd/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/sabnzbd/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/sabnzbd/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/sabnzbd/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/sabnzbd/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/sabnzbd/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/sabnzbd/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/sabnzbd/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/sabnzbd/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/sabnzbd/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/sabnzbd/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/sabnzbd/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/sabnzbd/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/sabnzbd/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/sabnzbd/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/sabnzbd/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/sabnzbd/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/sabnzbd/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/sabnzbd/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/sabnzbd/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/sabnzbd/templates/library/base_v2_1_8/validations.py b/ix-dev/community/sabnzbd/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/sabnzbd/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/sabnzbd/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/sabnzbd/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/sabnzbd/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/sabnzbd/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/sabnzbd/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/sabnzbd/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/sabnzbd/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/sabnzbd/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/sabnzbd/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/sabnzbd/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/sabnzbd/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/sabnzbd/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/sabnzbd/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/sabnzbd/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/sabnzbd/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/sabnzbd/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/sabnzbd/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/sabnzbd/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/sabnzbd/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/sabnzbd/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/sabnzbd/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/satisfactory-server/app.yaml b/ix-dev/community/satisfactory-server/app.yaml index 16ffc915ec..b7e4b0ea21 100644 --- a/ix-dev/community/satisfactory-server/app.yaml +++ b/ix-dev/community/satisfactory-server/app.yaml @@ -22,8 +22,8 @@ keywords: - games - server - satisfactory -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -40,4 +40,4 @@ sources: - https://github.com/wolveix/satisfactory-server title: Satisfactory Server train: community -version: 1.0.0 +version: 1.0.1 diff --git a/ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/container.py b/ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/ports.py b/ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/configs.py b/ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/container.py b/ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/depends.py b/ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/deps.py b/ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/device.py b/ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/device.py rename to ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/devices.py b/ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/dns.py b/ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/environment.py b/ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/error.py b/ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/error.py rename to ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/expose.py b/ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/functions.py b/ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/labels.py b/ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/notes.py b/ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/portal.py b/ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/portals.py b/ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/ports.py b/ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/render.py b/ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/render.py rename to ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/resources.py b/ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/restart.py b/ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/storage.py b/ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/validations.py b/ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/satisfactory-server/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/satisfactory-server/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/scrutiny/app.yaml b/ix-dev/community/scrutiny/app.yaml index 5c5640846d..fb928a8f10 100644 --- a/ix-dev/community/scrutiny/app.yaml +++ b/ix-dev/community/scrutiny/app.yaml @@ -22,8 +22,8 @@ icon: https://media.sys.truenas.net/apps/scrutiny/icons/icon.svg keywords: - disk - monitoring -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -41,4 +41,4 @@ sources: - https://github.com/AnalogJ/scrutiny title: Scrutiny train: community -version: 1.0.11 +version: 1.0.12 diff --git a/ix-dev/community/scrutiny/templates/library/base_v2_1_8/container.py b/ix-dev/community/scrutiny/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/scrutiny/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/scrutiny/templates/library/base_v2_1_8/ports.py b/ix-dev/community/scrutiny/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/scrutiny/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/scrutiny/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/scrutiny/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/scrutiny/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/scrutiny/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/scrutiny/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/scrutiny/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/scrutiny/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/scrutiny/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/scrutiny/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/scrutiny/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/scrutiny/templates/library/base_v2_1_8/configs.py b/ix-dev/community/scrutiny/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/scrutiny/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/scrutiny/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/scrutiny/templates/library/base_v2_1_9/container.py b/ix-dev/community/scrutiny/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/scrutiny/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/scrutiny/templates/library/base_v2_1_8/depends.py b/ix-dev/community/scrutiny/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/scrutiny/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/scrutiny/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/scrutiny/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/scrutiny/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/scrutiny/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/scrutiny/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/scrutiny/templates/library/base_v2_1_8/deps.py b/ix-dev/community/scrutiny/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/scrutiny/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/scrutiny/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/scrutiny/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/scrutiny/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/scrutiny/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/scrutiny/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/scrutiny/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/scrutiny/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/scrutiny/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/scrutiny/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/scrutiny/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/scrutiny/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/scrutiny/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/scrutiny/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/scrutiny/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/scrutiny/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/scrutiny/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/scrutiny/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/scrutiny/templates/library/base_v2_1_8/device.py b/ix-dev/community/scrutiny/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/scrutiny/templates/library/base_v2_1_8/device.py rename to ix-dev/community/scrutiny/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/scrutiny/templates/library/base_v2_1_8/devices.py b/ix-dev/community/scrutiny/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/scrutiny/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/scrutiny/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/scrutiny/templates/library/base_v2_1_8/dns.py b/ix-dev/community/scrutiny/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/scrutiny/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/scrutiny/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/scrutiny/templates/library/base_v2_1_8/environment.py b/ix-dev/community/scrutiny/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/scrutiny/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/scrutiny/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/scrutiny/templates/library/base_v2_1_8/error.py b/ix-dev/community/scrutiny/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/scrutiny/templates/library/base_v2_1_8/error.py rename to ix-dev/community/scrutiny/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/scrutiny/templates/library/base_v2_1_8/expose.py b/ix-dev/community/scrutiny/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/scrutiny/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/scrutiny/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/scrutiny/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/scrutiny/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/scrutiny/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/scrutiny/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/scrutiny/templates/library/base_v2_1_8/functions.py b/ix-dev/community/scrutiny/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/scrutiny/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/scrutiny/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/scrutiny/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/scrutiny/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/scrutiny/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/scrutiny/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/scrutiny/templates/library/base_v2_1_8/labels.py b/ix-dev/community/scrutiny/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/scrutiny/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/scrutiny/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/scrutiny/templates/library/base_v2_1_8/notes.py b/ix-dev/community/scrutiny/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/scrutiny/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/scrutiny/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/scrutiny/templates/library/base_v2_1_8/portal.py b/ix-dev/community/scrutiny/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/scrutiny/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/scrutiny/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/scrutiny/templates/library/base_v2_1_8/portals.py b/ix-dev/community/scrutiny/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/scrutiny/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/scrutiny/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/scrutiny/templates/library/base_v2_1_9/ports.py b/ix-dev/community/scrutiny/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/scrutiny/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/scrutiny/templates/library/base_v2_1_8/render.py b/ix-dev/community/scrutiny/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/scrutiny/templates/library/base_v2_1_8/render.py rename to ix-dev/community/scrutiny/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/scrutiny/templates/library/base_v2_1_8/resources.py b/ix-dev/community/scrutiny/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/scrutiny/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/scrutiny/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/scrutiny/templates/library/base_v2_1_8/restart.py b/ix-dev/community/scrutiny/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/scrutiny/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/scrutiny/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/scrutiny/templates/library/base_v2_1_8/storage.py b/ix-dev/community/scrutiny/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/scrutiny/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/scrutiny/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/scrutiny/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/scrutiny/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/scrutiny/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/scrutiny/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/scrutiny/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/scrutiny/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/scrutiny/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/scrutiny/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/scrutiny/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/scrutiny/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/scrutiny/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/scrutiny/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/scrutiny/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/scrutiny/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/scrutiny/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/scrutiny/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/scrutiny/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/scrutiny/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/scrutiny/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/scrutiny/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/scrutiny/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/scrutiny/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/scrutiny/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/scrutiny/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/scrutiny/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/scrutiny/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/scrutiny/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/scrutiny/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/scrutiny/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/scrutiny/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/scrutiny/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/scrutiny/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/scrutiny/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/scrutiny/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/scrutiny/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/scrutiny/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/scrutiny/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/scrutiny/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/scrutiny/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/scrutiny/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/scrutiny/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/scrutiny/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/scrutiny/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/scrutiny/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/scrutiny/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/scrutiny/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/scrutiny/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/scrutiny/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/scrutiny/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/scrutiny/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/scrutiny/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/scrutiny/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/scrutiny/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/scrutiny/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/scrutiny/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/scrutiny/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/scrutiny/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/scrutiny/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/scrutiny/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/scrutiny/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/scrutiny/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/scrutiny/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/scrutiny/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/scrutiny/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/scrutiny/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/scrutiny/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/scrutiny/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/scrutiny/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/scrutiny/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/scrutiny/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/scrutiny/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/scrutiny/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/scrutiny/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/scrutiny/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/scrutiny/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/scrutiny/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/scrutiny/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/scrutiny/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/scrutiny/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/scrutiny/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/scrutiny/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/scrutiny/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/scrutiny/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/scrutiny/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/scrutiny/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/scrutiny/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/scrutiny/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/scrutiny/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/scrutiny/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/scrutiny/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/scrutiny/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/scrutiny/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/scrutiny/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/scrutiny/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/scrutiny/templates/library/base_v2_1_8/validations.py b/ix-dev/community/scrutiny/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/scrutiny/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/scrutiny/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/scrutiny/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/scrutiny/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/scrutiny/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/scrutiny/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/scrutiny/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/scrutiny/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/scrutiny/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/scrutiny/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/scrutiny/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/scrutiny/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/scrutiny/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/scrutiny/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/scrutiny/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/scrutiny/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/scrutiny/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/scrutiny/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/scrutiny/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/scrutiny/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/scrutiny/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/scrutiny/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/searxng/app.yaml b/ix-dev/community/searxng/app.yaml index fa0db5f858..7341b99615 100644 --- a/ix-dev/community/searxng/app.yaml +++ b/ix-dev/community/searxng/app.yaml @@ -12,8 +12,8 @@ host_mounts: [] icon: https://media.sys.truenas.net/apps/searxng/icons/icon.svg keywords: - search -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -31,4 +31,4 @@ sources: - https://github.com/searxng/searxng title: SearXNG train: community -version: 1.1.14 +version: 1.1.15 diff --git a/ix-dev/community/searxng/templates/library/base_v2_1_8/container.py b/ix-dev/community/searxng/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/searxng/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/searxng/templates/library/base_v2_1_8/ports.py b/ix-dev/community/searxng/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/searxng/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/searxng/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/searxng/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/searxng/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/searxng/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/searxng/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/searxng/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/searxng/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/searxng/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/searxng/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/searxng/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/searxng/templates/library/base_v2_1_8/configs.py b/ix-dev/community/searxng/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/searxng/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/searxng/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/searxng/templates/library/base_v2_1_9/container.py b/ix-dev/community/searxng/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/searxng/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/searxng/templates/library/base_v2_1_8/depends.py b/ix-dev/community/searxng/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/searxng/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/searxng/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/searxng/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/searxng/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/searxng/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/searxng/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/searxng/templates/library/base_v2_1_8/deps.py b/ix-dev/community/searxng/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/searxng/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/searxng/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/searxng/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/searxng/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/searxng/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/searxng/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/searxng/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/searxng/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/searxng/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/searxng/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/searxng/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/searxng/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/searxng/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/searxng/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/searxng/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/searxng/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/searxng/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/searxng/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/searxng/templates/library/base_v2_1_8/device.py b/ix-dev/community/searxng/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/searxng/templates/library/base_v2_1_8/device.py rename to ix-dev/community/searxng/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/searxng/templates/library/base_v2_1_8/devices.py b/ix-dev/community/searxng/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/searxng/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/searxng/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/searxng/templates/library/base_v2_1_8/dns.py b/ix-dev/community/searxng/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/searxng/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/searxng/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/searxng/templates/library/base_v2_1_8/environment.py b/ix-dev/community/searxng/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/searxng/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/searxng/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/searxng/templates/library/base_v2_1_8/error.py b/ix-dev/community/searxng/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/searxng/templates/library/base_v2_1_8/error.py rename to ix-dev/community/searxng/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/searxng/templates/library/base_v2_1_8/expose.py b/ix-dev/community/searxng/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/searxng/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/searxng/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/searxng/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/searxng/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/searxng/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/searxng/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/searxng/templates/library/base_v2_1_8/functions.py b/ix-dev/community/searxng/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/searxng/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/searxng/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/searxng/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/searxng/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/searxng/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/searxng/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/searxng/templates/library/base_v2_1_8/labels.py b/ix-dev/community/searxng/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/searxng/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/searxng/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/searxng/templates/library/base_v2_1_8/notes.py b/ix-dev/community/searxng/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/searxng/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/searxng/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/searxng/templates/library/base_v2_1_8/portal.py b/ix-dev/community/searxng/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/searxng/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/searxng/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/searxng/templates/library/base_v2_1_8/portals.py b/ix-dev/community/searxng/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/searxng/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/searxng/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/searxng/templates/library/base_v2_1_9/ports.py b/ix-dev/community/searxng/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/searxng/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/searxng/templates/library/base_v2_1_8/render.py b/ix-dev/community/searxng/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/searxng/templates/library/base_v2_1_8/render.py rename to ix-dev/community/searxng/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/searxng/templates/library/base_v2_1_8/resources.py b/ix-dev/community/searxng/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/searxng/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/searxng/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/searxng/templates/library/base_v2_1_8/restart.py b/ix-dev/community/searxng/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/searxng/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/searxng/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/searxng/templates/library/base_v2_1_8/storage.py b/ix-dev/community/searxng/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/searxng/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/searxng/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/searxng/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/searxng/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/searxng/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/searxng/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/searxng/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/searxng/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/searxng/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/searxng/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/searxng/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/searxng/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/searxng/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/searxng/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/searxng/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/searxng/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/searxng/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/searxng/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/searxng/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/searxng/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/searxng/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/searxng/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/searxng/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/searxng/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/searxng/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/searxng/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/searxng/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/searxng/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/searxng/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/searxng/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/searxng/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/searxng/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/searxng/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/searxng/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/searxng/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/searxng/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/searxng/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/searxng/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/searxng/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/searxng/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/searxng/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/searxng/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/searxng/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/searxng/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/searxng/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/searxng/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/searxng/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/searxng/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/searxng/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/searxng/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/searxng/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/searxng/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/searxng/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/searxng/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/searxng/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/searxng/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/searxng/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/searxng/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/searxng/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/searxng/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/searxng/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/searxng/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/searxng/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/searxng/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/searxng/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/searxng/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/searxng/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/searxng/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/searxng/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/searxng/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/searxng/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/searxng/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/searxng/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/searxng/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/searxng/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/searxng/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/searxng/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/searxng/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/searxng/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/searxng/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/searxng/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/searxng/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/searxng/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/searxng/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/searxng/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/searxng/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/searxng/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/searxng/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/searxng/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/searxng/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/searxng/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/searxng/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/searxng/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/searxng/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/searxng/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/searxng/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/searxng/templates/library/base_v2_1_8/validations.py b/ix-dev/community/searxng/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/searxng/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/searxng/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/searxng/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/searxng/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/searxng/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/searxng/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/searxng/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/searxng/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/searxng/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/searxng/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/searxng/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/searxng/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/searxng/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/searxng/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/searxng/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/searxng/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/searxng/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/searxng/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/searxng/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/searxng/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/searxng/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/searxng/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/sftpgo/app.yaml b/ix-dev/community/sftpgo/app.yaml index 12daab2d93..2cf395d1a5 100644 --- a/ix-dev/community/sftpgo/app.yaml +++ b/ix-dev/community/sftpgo/app.yaml @@ -9,8 +9,8 @@ host_mounts: [] icon: https://media.sys.truenas.net/apps/sftpgo/icons/icon.png keywords: - sftp -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -28,4 +28,4 @@ sources: - https://github.com/drakkan/sftpgo title: SFTPGo train: community -version: 1.1.5 +version: 1.1.6 diff --git a/ix-dev/community/sftpgo/templates/library/base_v2_1_8/container.py b/ix-dev/community/sftpgo/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/sftpgo/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/sftpgo/templates/library/base_v2_1_8/ports.py b/ix-dev/community/sftpgo/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/sftpgo/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/sftpgo/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/sftpgo/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/sftpgo/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/sftpgo/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/sftpgo/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/sftpgo/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/sftpgo/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/sftpgo/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/sftpgo/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/sftpgo/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/sftpgo/templates/library/base_v2_1_8/configs.py b/ix-dev/community/sftpgo/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/sftpgo/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/sftpgo/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/sftpgo/templates/library/base_v2_1_9/container.py b/ix-dev/community/sftpgo/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/sftpgo/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/sftpgo/templates/library/base_v2_1_8/depends.py b/ix-dev/community/sftpgo/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/sftpgo/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/sftpgo/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/sftpgo/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/sftpgo/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/sftpgo/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/sftpgo/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/sftpgo/templates/library/base_v2_1_8/deps.py b/ix-dev/community/sftpgo/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/sftpgo/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/sftpgo/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/sftpgo/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/sftpgo/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/sftpgo/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/sftpgo/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/sftpgo/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/sftpgo/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/sftpgo/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/sftpgo/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/sftpgo/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/sftpgo/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/sftpgo/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/sftpgo/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/sftpgo/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/sftpgo/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/sftpgo/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/sftpgo/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/sftpgo/templates/library/base_v2_1_8/device.py b/ix-dev/community/sftpgo/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/sftpgo/templates/library/base_v2_1_8/device.py rename to ix-dev/community/sftpgo/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/sftpgo/templates/library/base_v2_1_8/devices.py b/ix-dev/community/sftpgo/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/sftpgo/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/sftpgo/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/sftpgo/templates/library/base_v2_1_8/dns.py b/ix-dev/community/sftpgo/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/sftpgo/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/sftpgo/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/sftpgo/templates/library/base_v2_1_8/environment.py b/ix-dev/community/sftpgo/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/sftpgo/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/sftpgo/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/sftpgo/templates/library/base_v2_1_8/error.py b/ix-dev/community/sftpgo/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/sftpgo/templates/library/base_v2_1_8/error.py rename to ix-dev/community/sftpgo/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/sftpgo/templates/library/base_v2_1_8/expose.py b/ix-dev/community/sftpgo/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/sftpgo/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/sftpgo/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/sftpgo/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/sftpgo/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/sftpgo/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/sftpgo/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/sftpgo/templates/library/base_v2_1_8/functions.py b/ix-dev/community/sftpgo/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/sftpgo/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/sftpgo/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/sftpgo/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/sftpgo/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/sftpgo/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/sftpgo/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/sftpgo/templates/library/base_v2_1_8/labels.py b/ix-dev/community/sftpgo/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/sftpgo/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/sftpgo/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/sftpgo/templates/library/base_v2_1_8/notes.py b/ix-dev/community/sftpgo/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/sftpgo/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/sftpgo/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/sftpgo/templates/library/base_v2_1_8/portal.py b/ix-dev/community/sftpgo/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/sftpgo/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/sftpgo/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/sftpgo/templates/library/base_v2_1_8/portals.py b/ix-dev/community/sftpgo/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/sftpgo/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/sftpgo/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/sftpgo/templates/library/base_v2_1_9/ports.py b/ix-dev/community/sftpgo/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/sftpgo/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/sftpgo/templates/library/base_v2_1_8/render.py b/ix-dev/community/sftpgo/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/sftpgo/templates/library/base_v2_1_8/render.py rename to ix-dev/community/sftpgo/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/sftpgo/templates/library/base_v2_1_8/resources.py b/ix-dev/community/sftpgo/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/sftpgo/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/sftpgo/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/sftpgo/templates/library/base_v2_1_8/restart.py b/ix-dev/community/sftpgo/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/sftpgo/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/sftpgo/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/sftpgo/templates/library/base_v2_1_8/storage.py b/ix-dev/community/sftpgo/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/sftpgo/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/sftpgo/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/sftpgo/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/sftpgo/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/sftpgo/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/sftpgo/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/sftpgo/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/sftpgo/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/sftpgo/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/sftpgo/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/sftpgo/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/sftpgo/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/sftpgo/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/sftpgo/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/sftpgo/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/sftpgo/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/sftpgo/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/sftpgo/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/sftpgo/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/sftpgo/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/sftpgo/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/sftpgo/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/sftpgo/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/sftpgo/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/sftpgo/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/sftpgo/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/sftpgo/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/sftpgo/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/sftpgo/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/sftpgo/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/sftpgo/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/sftpgo/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/sftpgo/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/sftpgo/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/sftpgo/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/sftpgo/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/sftpgo/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/sftpgo/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/sftpgo/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/sftpgo/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/sftpgo/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/sftpgo/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/sftpgo/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/sftpgo/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/sftpgo/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/sftpgo/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/sftpgo/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/sftpgo/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/sftpgo/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/sftpgo/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/sftpgo/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/sftpgo/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/sftpgo/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/sftpgo/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/sftpgo/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/sftpgo/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/sftpgo/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/sftpgo/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/sftpgo/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/sftpgo/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/sftpgo/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/sftpgo/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/sftpgo/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/sftpgo/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/sftpgo/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/sftpgo/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/sftpgo/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/sftpgo/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/sftpgo/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/sftpgo/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/sftpgo/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/sftpgo/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/sftpgo/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/sftpgo/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/sftpgo/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/sftpgo/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/sftpgo/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/sftpgo/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/sftpgo/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/sftpgo/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/sftpgo/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/sftpgo/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/sftpgo/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/sftpgo/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/sftpgo/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/sftpgo/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/sftpgo/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/sftpgo/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/sftpgo/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/sftpgo/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/sftpgo/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/sftpgo/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/sftpgo/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/sftpgo/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/sftpgo/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/sftpgo/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/sftpgo/templates/library/base_v2_1_8/validations.py b/ix-dev/community/sftpgo/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/sftpgo/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/sftpgo/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/sftpgo/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/sftpgo/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/sftpgo/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/sftpgo/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/sftpgo/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/sftpgo/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/sftpgo/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/sftpgo/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/sftpgo/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/sftpgo/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/sftpgo/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/sftpgo/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/sftpgo/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/sftpgo/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/sftpgo/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/sftpgo/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/sftpgo/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/sftpgo/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/sftpgo/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/sftpgo/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/sonarr/app.yaml b/ix-dev/community/sonarr/app.yaml index b3a94a870b..6a9585d49f 100644 --- a/ix-dev/community/sonarr/app.yaml +++ b/ix-dev/community/sonarr/app.yaml @@ -9,8 +9,8 @@ icon: https://media.sys.truenas.net/apps/sonarr/icons/icon.png keywords: - media - series -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -31,4 +31,4 @@ sources: - https://github.com/Sonarr/Sonarr title: Sonarr train: community -version: 1.1.8 +version: 1.1.9 diff --git a/ix-dev/community/sonarr/templates/library/base_v2_1_8/container.py b/ix-dev/community/sonarr/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/sonarr/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/sonarr/templates/library/base_v2_1_8/ports.py b/ix-dev/community/sonarr/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/sonarr/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/sonarr/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/sonarr/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/sonarr/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/sonarr/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/sonarr/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/sonarr/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/sonarr/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/sonarr/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/sonarr/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/sonarr/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/sonarr/templates/library/base_v2_1_8/configs.py b/ix-dev/community/sonarr/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/sonarr/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/sonarr/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/sonarr/templates/library/base_v2_1_9/container.py b/ix-dev/community/sonarr/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/sonarr/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/sonarr/templates/library/base_v2_1_8/depends.py b/ix-dev/community/sonarr/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/sonarr/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/sonarr/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/sonarr/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/sonarr/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/sonarr/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/sonarr/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/sonarr/templates/library/base_v2_1_8/deps.py b/ix-dev/community/sonarr/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/sonarr/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/sonarr/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/sonarr/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/sonarr/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/sonarr/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/sonarr/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/sonarr/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/sonarr/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/sonarr/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/sonarr/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/sonarr/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/sonarr/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/sonarr/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/sonarr/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/sonarr/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/sonarr/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/sonarr/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/sonarr/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/sonarr/templates/library/base_v2_1_8/device.py b/ix-dev/community/sonarr/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/sonarr/templates/library/base_v2_1_8/device.py rename to ix-dev/community/sonarr/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/sonarr/templates/library/base_v2_1_8/devices.py b/ix-dev/community/sonarr/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/sonarr/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/sonarr/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/sonarr/templates/library/base_v2_1_8/dns.py b/ix-dev/community/sonarr/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/sonarr/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/sonarr/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/sonarr/templates/library/base_v2_1_8/environment.py b/ix-dev/community/sonarr/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/sonarr/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/sonarr/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/sonarr/templates/library/base_v2_1_8/error.py b/ix-dev/community/sonarr/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/sonarr/templates/library/base_v2_1_8/error.py rename to ix-dev/community/sonarr/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/sonarr/templates/library/base_v2_1_8/expose.py b/ix-dev/community/sonarr/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/sonarr/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/sonarr/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/sonarr/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/sonarr/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/sonarr/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/sonarr/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/sonarr/templates/library/base_v2_1_8/functions.py b/ix-dev/community/sonarr/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/sonarr/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/sonarr/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/sonarr/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/sonarr/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/sonarr/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/sonarr/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/sonarr/templates/library/base_v2_1_8/labels.py b/ix-dev/community/sonarr/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/sonarr/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/sonarr/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/sonarr/templates/library/base_v2_1_8/notes.py b/ix-dev/community/sonarr/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/sonarr/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/sonarr/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/sonarr/templates/library/base_v2_1_8/portal.py b/ix-dev/community/sonarr/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/sonarr/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/sonarr/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/sonarr/templates/library/base_v2_1_8/portals.py b/ix-dev/community/sonarr/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/sonarr/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/sonarr/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/sonarr/templates/library/base_v2_1_9/ports.py b/ix-dev/community/sonarr/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/sonarr/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/sonarr/templates/library/base_v2_1_8/render.py b/ix-dev/community/sonarr/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/sonarr/templates/library/base_v2_1_8/render.py rename to ix-dev/community/sonarr/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/sonarr/templates/library/base_v2_1_8/resources.py b/ix-dev/community/sonarr/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/sonarr/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/sonarr/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/sonarr/templates/library/base_v2_1_8/restart.py b/ix-dev/community/sonarr/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/sonarr/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/sonarr/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/sonarr/templates/library/base_v2_1_8/storage.py b/ix-dev/community/sonarr/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/sonarr/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/sonarr/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/sonarr/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/sonarr/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/sonarr/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/sonarr/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/sonarr/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/sonarr/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/sonarr/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/sonarr/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/sonarr/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/sonarr/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/sonarr/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/sonarr/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/sonarr/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/sonarr/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/sonarr/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/sonarr/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/sonarr/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/sonarr/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/sonarr/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/sonarr/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/sonarr/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/sonarr/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/sonarr/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/sonarr/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/sonarr/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/sonarr/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/sonarr/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/sonarr/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/sonarr/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/sonarr/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/sonarr/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/sonarr/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/sonarr/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/sonarr/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/sonarr/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/sonarr/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/sonarr/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/sonarr/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/sonarr/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/sonarr/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/sonarr/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/sonarr/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/sonarr/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/sonarr/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/sonarr/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/sonarr/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/sonarr/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/sonarr/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/sonarr/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/sonarr/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/sonarr/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/sonarr/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/sonarr/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/sonarr/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/sonarr/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/sonarr/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/sonarr/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/sonarr/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/sonarr/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/sonarr/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/sonarr/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/sonarr/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/sonarr/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/sonarr/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/sonarr/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/sonarr/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/sonarr/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/sonarr/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/sonarr/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/sonarr/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/sonarr/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/sonarr/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/sonarr/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/sonarr/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/sonarr/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/sonarr/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/sonarr/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/sonarr/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/sonarr/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/sonarr/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/sonarr/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/sonarr/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/sonarr/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/sonarr/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/sonarr/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/sonarr/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/sonarr/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/sonarr/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/sonarr/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/sonarr/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/sonarr/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/sonarr/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/sonarr/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/sonarr/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/sonarr/templates/library/base_v2_1_8/validations.py b/ix-dev/community/sonarr/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/sonarr/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/sonarr/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/sonarr/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/sonarr/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/sonarr/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/sonarr/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/sonarr/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/sonarr/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/sonarr/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/sonarr/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/sonarr/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/sonarr/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/sonarr/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/sonarr/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/sonarr/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/sonarr/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/sonarr/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/sonarr/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/sonarr/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/sonarr/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/sonarr/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/sonarr/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/tailscale/app.yaml b/ix-dev/community/tailscale/app.yaml index 9c596da2e9..321c3d75ed 100644 --- a/ix-dev/community/tailscale/app.yaml +++ b/ix-dev/community/tailscale/app.yaml @@ -23,8 +23,8 @@ icon: https://media.sys.truenas.net/apps/tailscale/icons/icon.png keywords: - vpn - tailscale -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -42,4 +42,4 @@ sources: - https://hub.docker.com/r/tailscale/tailscale title: Tailscale train: community -version: 1.2.8 +version: 1.2.9 diff --git a/ix-dev/community/tailscale/templates/library/base_v2_1_8/container.py b/ix-dev/community/tailscale/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/tailscale/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/tailscale/templates/library/base_v2_1_8/ports.py b/ix-dev/community/tailscale/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/tailscale/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/tailscale/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/tailscale/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/tailscale/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/tailscale/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/tailscale/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/tailscale/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/tailscale/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/tailscale/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/tailscale/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/tailscale/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/tailscale/templates/library/base_v2_1_8/configs.py b/ix-dev/community/tailscale/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/tailscale/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/tailscale/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/tailscale/templates/library/base_v2_1_9/container.py b/ix-dev/community/tailscale/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/tailscale/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/tailscale/templates/library/base_v2_1_8/depends.py b/ix-dev/community/tailscale/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/tailscale/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/tailscale/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/tailscale/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/tailscale/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/tailscale/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/tailscale/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/tailscale/templates/library/base_v2_1_8/deps.py b/ix-dev/community/tailscale/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/tailscale/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/tailscale/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/tailscale/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/tailscale/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/tailscale/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/tailscale/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/tailscale/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/tailscale/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/tailscale/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/tailscale/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/tailscale/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/tailscale/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/tailscale/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/tailscale/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/tailscale/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/tailscale/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/tailscale/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/tailscale/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/tailscale/templates/library/base_v2_1_8/device.py b/ix-dev/community/tailscale/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/tailscale/templates/library/base_v2_1_8/device.py rename to ix-dev/community/tailscale/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/tailscale/templates/library/base_v2_1_8/devices.py b/ix-dev/community/tailscale/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/tailscale/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/tailscale/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/tailscale/templates/library/base_v2_1_8/dns.py b/ix-dev/community/tailscale/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/tailscale/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/tailscale/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/tailscale/templates/library/base_v2_1_8/environment.py b/ix-dev/community/tailscale/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/tailscale/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/tailscale/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/tailscale/templates/library/base_v2_1_8/error.py b/ix-dev/community/tailscale/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/tailscale/templates/library/base_v2_1_8/error.py rename to ix-dev/community/tailscale/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/tailscale/templates/library/base_v2_1_8/expose.py b/ix-dev/community/tailscale/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/tailscale/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/tailscale/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/tailscale/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/tailscale/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/tailscale/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/tailscale/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/tailscale/templates/library/base_v2_1_8/functions.py b/ix-dev/community/tailscale/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/tailscale/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/tailscale/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/tailscale/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/tailscale/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/tailscale/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/tailscale/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/tailscale/templates/library/base_v2_1_8/labels.py b/ix-dev/community/tailscale/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/tailscale/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/tailscale/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/tailscale/templates/library/base_v2_1_8/notes.py b/ix-dev/community/tailscale/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/tailscale/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/tailscale/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/tailscale/templates/library/base_v2_1_8/portal.py b/ix-dev/community/tailscale/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/tailscale/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/tailscale/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/tailscale/templates/library/base_v2_1_8/portals.py b/ix-dev/community/tailscale/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/tailscale/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/tailscale/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/tailscale/templates/library/base_v2_1_9/ports.py b/ix-dev/community/tailscale/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/tailscale/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/tailscale/templates/library/base_v2_1_8/render.py b/ix-dev/community/tailscale/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/tailscale/templates/library/base_v2_1_8/render.py rename to ix-dev/community/tailscale/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/tailscale/templates/library/base_v2_1_8/resources.py b/ix-dev/community/tailscale/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/tailscale/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/tailscale/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/tailscale/templates/library/base_v2_1_8/restart.py b/ix-dev/community/tailscale/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/tailscale/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/tailscale/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/tailscale/templates/library/base_v2_1_8/storage.py b/ix-dev/community/tailscale/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/tailscale/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/tailscale/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/tailscale/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/tailscale/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/tailscale/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/tailscale/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/tailscale/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/tailscale/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/tailscale/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/tailscale/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/tailscale/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/tailscale/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/tailscale/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/tailscale/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/tailscale/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/tailscale/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/tailscale/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/tailscale/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/tailscale/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/tailscale/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/tailscale/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/tailscale/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/tailscale/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/tailscale/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/tailscale/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/tailscale/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/tailscale/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/tailscale/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/tailscale/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/tailscale/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/tailscale/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/tailscale/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/tailscale/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/tailscale/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/tailscale/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/tailscale/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/tailscale/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/tailscale/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/tailscale/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/tailscale/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/tailscale/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/tailscale/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/tailscale/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/tailscale/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/tailscale/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/tailscale/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/tailscale/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/tailscale/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/tailscale/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/tailscale/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/tailscale/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/tailscale/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/tailscale/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/tailscale/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/tailscale/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/tailscale/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/tailscale/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/tailscale/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/tailscale/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/tailscale/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/tailscale/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/tailscale/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/tailscale/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/tailscale/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/tailscale/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/tailscale/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/tailscale/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/tailscale/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/tailscale/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/tailscale/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/tailscale/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/tailscale/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/tailscale/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/tailscale/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/tailscale/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/tailscale/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/tailscale/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/tailscale/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/tailscale/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/tailscale/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/tailscale/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/tailscale/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/tailscale/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/tailscale/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/tailscale/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/tailscale/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/tailscale/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/tailscale/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/tailscale/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/tailscale/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/tailscale/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/tailscale/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/tailscale/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/tailscale/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/tailscale/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/tailscale/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/tailscale/templates/library/base_v2_1_8/validations.py b/ix-dev/community/tailscale/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/tailscale/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/tailscale/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/tailscale/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/tailscale/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/tailscale/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/tailscale/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/tailscale/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/tailscale/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/tailscale/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/tailscale/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/tailscale/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/tailscale/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/tailscale/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/tailscale/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/tailscale/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/tailscale/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/tailscale/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/tailscale/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/tailscale/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/tailscale/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/tailscale/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/tailscale/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/tautulli/app.yaml b/ix-dev/community/tautulli/app.yaml index bff37d65bb..78b6ef6c2a 100644 --- a/ix-dev/community/tautulli/app.yaml +++ b/ix-dev/community/tautulli/app.yaml @@ -11,8 +11,8 @@ keywords: - media - analytics - notifications -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -34,4 +34,4 @@ sources: - https://github.com/Tautulli/Tautulli title: Tautulli train: community -version: 1.1.8 +version: 1.1.9 diff --git a/ix-dev/community/tautulli/templates/library/base_v2_1_8/container.py b/ix-dev/community/tautulli/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/tautulli/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/tautulli/templates/library/base_v2_1_8/ports.py b/ix-dev/community/tautulli/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/tautulli/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/tautulli/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/tautulli/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/tautulli/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/tautulli/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/tautulli/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/tautulli/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/tautulli/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/tautulli/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/tautulli/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/tautulli/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/tautulli/templates/library/base_v2_1_8/configs.py b/ix-dev/community/tautulli/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/tautulli/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/tautulli/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/tautulli/templates/library/base_v2_1_9/container.py b/ix-dev/community/tautulli/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/tautulli/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/tautulli/templates/library/base_v2_1_8/depends.py b/ix-dev/community/tautulli/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/tautulli/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/tautulli/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/tautulli/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/tautulli/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/tautulli/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/tautulli/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/tautulli/templates/library/base_v2_1_8/deps.py b/ix-dev/community/tautulli/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/tautulli/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/tautulli/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/tautulli/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/tautulli/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/tautulli/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/tautulli/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/tautulli/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/tautulli/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/tautulli/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/tautulli/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/tautulli/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/tautulli/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/tautulli/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/tautulli/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/tautulli/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/tautulli/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/tautulli/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/tautulli/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/tautulli/templates/library/base_v2_1_8/device.py b/ix-dev/community/tautulli/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/tautulli/templates/library/base_v2_1_8/device.py rename to ix-dev/community/tautulli/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/tautulli/templates/library/base_v2_1_8/devices.py b/ix-dev/community/tautulli/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/tautulli/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/tautulli/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/tautulli/templates/library/base_v2_1_8/dns.py b/ix-dev/community/tautulli/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/tautulli/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/tautulli/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/tautulli/templates/library/base_v2_1_8/environment.py b/ix-dev/community/tautulli/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/tautulli/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/tautulli/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/tautulli/templates/library/base_v2_1_8/error.py b/ix-dev/community/tautulli/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/tautulli/templates/library/base_v2_1_8/error.py rename to ix-dev/community/tautulli/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/tautulli/templates/library/base_v2_1_8/expose.py b/ix-dev/community/tautulli/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/tautulli/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/tautulli/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/tautulli/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/tautulli/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/tautulli/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/tautulli/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/tautulli/templates/library/base_v2_1_8/functions.py b/ix-dev/community/tautulli/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/tautulli/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/tautulli/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/tautulli/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/tautulli/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/tautulli/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/tautulli/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/tautulli/templates/library/base_v2_1_8/labels.py b/ix-dev/community/tautulli/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/tautulli/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/tautulli/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/tautulli/templates/library/base_v2_1_8/notes.py b/ix-dev/community/tautulli/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/tautulli/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/tautulli/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/tautulli/templates/library/base_v2_1_8/portal.py b/ix-dev/community/tautulli/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/tautulli/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/tautulli/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/tautulli/templates/library/base_v2_1_8/portals.py b/ix-dev/community/tautulli/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/tautulli/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/tautulli/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/tautulli/templates/library/base_v2_1_9/ports.py b/ix-dev/community/tautulli/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/tautulli/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/tautulli/templates/library/base_v2_1_8/render.py b/ix-dev/community/tautulli/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/tautulli/templates/library/base_v2_1_8/render.py rename to ix-dev/community/tautulli/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/tautulli/templates/library/base_v2_1_8/resources.py b/ix-dev/community/tautulli/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/tautulli/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/tautulli/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/tautulli/templates/library/base_v2_1_8/restart.py b/ix-dev/community/tautulli/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/tautulli/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/tautulli/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/tautulli/templates/library/base_v2_1_8/storage.py b/ix-dev/community/tautulli/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/tautulli/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/tautulli/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/tautulli/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/tautulli/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/tautulli/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/tautulli/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/tautulli/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/tautulli/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/tautulli/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/tautulli/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/tautulli/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/tautulli/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/tautulli/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/tautulli/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/tautulli/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/tautulli/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/tautulli/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/tautulli/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/tautulli/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/tautulli/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/tautulli/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/tautulli/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/tautulli/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/tautulli/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/tautulli/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/tautulli/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/tautulli/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/tautulli/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/tautulli/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/tautulli/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/tautulli/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/tautulli/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/tautulli/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/tautulli/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/tautulli/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/tautulli/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/tautulli/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/tautulli/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/tautulli/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/tautulli/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/tautulli/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/tautulli/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/tautulli/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/tautulli/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/tautulli/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/tautulli/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/tautulli/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/tautulli/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/tautulli/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/tautulli/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/tautulli/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/tautulli/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/tautulli/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/tautulli/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/tautulli/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/tautulli/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/tautulli/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/tautulli/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/tautulli/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/tautulli/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/tautulli/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/tautulli/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/tautulli/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/tautulli/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/tautulli/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/tautulli/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/tautulli/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/tautulli/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/tautulli/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/tautulli/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/tautulli/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/tautulli/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/tautulli/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/tautulli/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/tautulli/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/tautulli/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/tautulli/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/tautulli/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/tautulli/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/tautulli/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/tautulli/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/tautulli/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/tautulli/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/tautulli/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/tautulli/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/tautulli/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/tautulli/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/tautulli/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/tautulli/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/tautulli/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/tautulli/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/tautulli/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/tautulli/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/tautulli/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/tautulli/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/tautulli/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/tautulli/templates/library/base_v2_1_8/validations.py b/ix-dev/community/tautulli/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/tautulli/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/tautulli/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/tautulli/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/tautulli/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/tautulli/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/tautulli/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/tautulli/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/tautulli/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/tautulli/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/tautulli/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/tautulli/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/tautulli/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/tautulli/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/tautulli/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/tautulli/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/tautulli/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/tautulli/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/tautulli/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/tautulli/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/tautulli/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/tautulli/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/tautulli/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/tdarr/app.yaml b/ix-dev/community/tdarr/app.yaml index c242db0f1f..62c2468187 100644 --- a/ix-dev/community/tdarr/app.yaml +++ b/ix-dev/community/tdarr/app.yaml @@ -18,8 +18,8 @@ icon: https://media.sys.truenas.net/apps/tdarr/icons/icon.png keywords: - encode - transcode -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -40,4 +40,4 @@ sources: - https://docs.tdarr.io/docs title: Tdarr train: community -version: 1.1.5 +version: 1.1.6 diff --git a/ix-dev/community/tdarr/templates/library/base_v2_1_8/container.py b/ix-dev/community/tdarr/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/tdarr/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/tdarr/templates/library/base_v2_1_8/ports.py b/ix-dev/community/tdarr/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/tdarr/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/tdarr/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/tdarr/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/tdarr/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/tdarr/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/tdarr/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/tdarr/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/tdarr/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/tdarr/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/tdarr/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/tdarr/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/tdarr/templates/library/base_v2_1_8/configs.py b/ix-dev/community/tdarr/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/tdarr/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/tdarr/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/tdarr/templates/library/base_v2_1_9/container.py b/ix-dev/community/tdarr/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/tdarr/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/tdarr/templates/library/base_v2_1_8/depends.py b/ix-dev/community/tdarr/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/tdarr/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/tdarr/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/tdarr/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/tdarr/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/tdarr/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/tdarr/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/tdarr/templates/library/base_v2_1_8/deps.py b/ix-dev/community/tdarr/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/tdarr/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/tdarr/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/tdarr/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/tdarr/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/tdarr/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/tdarr/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/tdarr/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/tdarr/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/tdarr/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/tdarr/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/tdarr/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/tdarr/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/tdarr/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/tdarr/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/tdarr/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/tdarr/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/tdarr/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/tdarr/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/tdarr/templates/library/base_v2_1_8/device.py b/ix-dev/community/tdarr/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/tdarr/templates/library/base_v2_1_8/device.py rename to ix-dev/community/tdarr/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/tdarr/templates/library/base_v2_1_8/devices.py b/ix-dev/community/tdarr/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/tdarr/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/tdarr/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/tdarr/templates/library/base_v2_1_8/dns.py b/ix-dev/community/tdarr/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/tdarr/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/tdarr/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/tdarr/templates/library/base_v2_1_8/environment.py b/ix-dev/community/tdarr/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/tdarr/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/tdarr/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/tdarr/templates/library/base_v2_1_8/error.py b/ix-dev/community/tdarr/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/tdarr/templates/library/base_v2_1_8/error.py rename to ix-dev/community/tdarr/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/tdarr/templates/library/base_v2_1_8/expose.py b/ix-dev/community/tdarr/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/tdarr/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/tdarr/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/tdarr/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/tdarr/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/tdarr/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/tdarr/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/tdarr/templates/library/base_v2_1_8/functions.py b/ix-dev/community/tdarr/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/tdarr/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/tdarr/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/tdarr/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/tdarr/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/tdarr/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/tdarr/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/tdarr/templates/library/base_v2_1_8/labels.py b/ix-dev/community/tdarr/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/tdarr/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/tdarr/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/tdarr/templates/library/base_v2_1_8/notes.py b/ix-dev/community/tdarr/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/tdarr/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/tdarr/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/tdarr/templates/library/base_v2_1_8/portal.py b/ix-dev/community/tdarr/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/tdarr/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/tdarr/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/tdarr/templates/library/base_v2_1_8/portals.py b/ix-dev/community/tdarr/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/tdarr/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/tdarr/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/tdarr/templates/library/base_v2_1_9/ports.py b/ix-dev/community/tdarr/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/tdarr/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/tdarr/templates/library/base_v2_1_8/render.py b/ix-dev/community/tdarr/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/tdarr/templates/library/base_v2_1_8/render.py rename to ix-dev/community/tdarr/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/tdarr/templates/library/base_v2_1_8/resources.py b/ix-dev/community/tdarr/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/tdarr/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/tdarr/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/tdarr/templates/library/base_v2_1_8/restart.py b/ix-dev/community/tdarr/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/tdarr/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/tdarr/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/tdarr/templates/library/base_v2_1_8/storage.py b/ix-dev/community/tdarr/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/tdarr/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/tdarr/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/tdarr/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/tdarr/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/tdarr/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/tdarr/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/tdarr/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/tdarr/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/tdarr/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/tdarr/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/tdarr/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/tdarr/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/tdarr/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/tdarr/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/tdarr/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/tdarr/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/tdarr/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/tdarr/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/tdarr/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/tdarr/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/tdarr/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/tdarr/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/tdarr/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/tdarr/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/tdarr/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/tdarr/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/tdarr/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/tdarr/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/tdarr/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/tdarr/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/tdarr/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/tdarr/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/tdarr/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/tdarr/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/tdarr/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/tdarr/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/tdarr/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/tdarr/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/tdarr/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/tdarr/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/tdarr/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/tdarr/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/tdarr/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/tdarr/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/tdarr/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/tdarr/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/tdarr/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/tdarr/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/tdarr/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/tdarr/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/tdarr/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/tdarr/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/tdarr/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/tdarr/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/tdarr/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/tdarr/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/tdarr/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/tdarr/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/tdarr/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/tdarr/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/tdarr/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/tdarr/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/tdarr/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/tdarr/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/tdarr/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/tdarr/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/tdarr/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/tdarr/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/tdarr/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/tdarr/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/tdarr/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/tdarr/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/tdarr/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/tdarr/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/tdarr/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/tdarr/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/tdarr/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/tdarr/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/tdarr/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/tdarr/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/tdarr/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/tdarr/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/tdarr/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/tdarr/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/tdarr/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/tdarr/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/tdarr/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/tdarr/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/tdarr/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/tdarr/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/tdarr/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/tdarr/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/tdarr/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/tdarr/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/tdarr/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/tdarr/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/tdarr/templates/library/base_v2_1_8/validations.py b/ix-dev/community/tdarr/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/tdarr/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/tdarr/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/tdarr/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/tdarr/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/tdarr/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/tdarr/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/tdarr/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/tdarr/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/tdarr/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/tdarr/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/tdarr/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/tdarr/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/tdarr/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/tdarr/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/tdarr/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/tdarr/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/tdarr/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/tdarr/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/tdarr/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/tdarr/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/tdarr/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/tdarr/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/terraria/app.yaml b/ix-dev/community/terraria/app.yaml index 7b76694f59..5487fe7344 100644 --- a/ix-dev/community/terraria/app.yaml +++ b/ix-dev/community/terraria/app.yaml @@ -13,8 +13,8 @@ keywords: - game - terraria - world -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -32,4 +32,4 @@ sources: - https://github.com/ryansheehan/terraria title: Terraria train: community -version: 1.1.6 +version: 1.1.7 diff --git a/ix-dev/community/terraria/templates/library/base_v2_1_8/container.py b/ix-dev/community/terraria/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/terraria/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/terraria/templates/library/base_v2_1_8/ports.py b/ix-dev/community/terraria/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/terraria/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/terraria/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/terraria/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/terraria/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/terraria/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/terraria/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/terraria/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/terraria/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/terraria/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/terraria/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/terraria/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/terraria/templates/library/base_v2_1_8/configs.py b/ix-dev/community/terraria/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/terraria/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/terraria/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/terraria/templates/library/base_v2_1_9/container.py b/ix-dev/community/terraria/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/terraria/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/terraria/templates/library/base_v2_1_8/depends.py b/ix-dev/community/terraria/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/terraria/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/terraria/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/terraria/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/terraria/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/terraria/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/terraria/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/terraria/templates/library/base_v2_1_8/deps.py b/ix-dev/community/terraria/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/terraria/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/terraria/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/terraria/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/terraria/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/terraria/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/terraria/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/terraria/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/terraria/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/terraria/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/terraria/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/terraria/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/terraria/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/terraria/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/terraria/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/terraria/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/terraria/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/terraria/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/terraria/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/terraria/templates/library/base_v2_1_8/device.py b/ix-dev/community/terraria/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/terraria/templates/library/base_v2_1_8/device.py rename to ix-dev/community/terraria/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/terraria/templates/library/base_v2_1_8/devices.py b/ix-dev/community/terraria/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/terraria/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/terraria/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/terraria/templates/library/base_v2_1_8/dns.py b/ix-dev/community/terraria/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/terraria/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/terraria/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/terraria/templates/library/base_v2_1_8/environment.py b/ix-dev/community/terraria/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/terraria/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/terraria/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/terraria/templates/library/base_v2_1_8/error.py b/ix-dev/community/terraria/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/terraria/templates/library/base_v2_1_8/error.py rename to ix-dev/community/terraria/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/terraria/templates/library/base_v2_1_8/expose.py b/ix-dev/community/terraria/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/terraria/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/terraria/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/terraria/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/terraria/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/terraria/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/terraria/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/terraria/templates/library/base_v2_1_8/functions.py b/ix-dev/community/terraria/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/terraria/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/terraria/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/terraria/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/terraria/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/terraria/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/terraria/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/terraria/templates/library/base_v2_1_8/labels.py b/ix-dev/community/terraria/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/terraria/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/terraria/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/terraria/templates/library/base_v2_1_8/notes.py b/ix-dev/community/terraria/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/terraria/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/terraria/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/terraria/templates/library/base_v2_1_8/portal.py b/ix-dev/community/terraria/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/terraria/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/terraria/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/terraria/templates/library/base_v2_1_8/portals.py b/ix-dev/community/terraria/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/terraria/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/terraria/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/terraria/templates/library/base_v2_1_9/ports.py b/ix-dev/community/terraria/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/terraria/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/terraria/templates/library/base_v2_1_8/render.py b/ix-dev/community/terraria/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/terraria/templates/library/base_v2_1_8/render.py rename to ix-dev/community/terraria/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/terraria/templates/library/base_v2_1_8/resources.py b/ix-dev/community/terraria/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/terraria/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/terraria/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/terraria/templates/library/base_v2_1_8/restart.py b/ix-dev/community/terraria/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/terraria/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/terraria/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/terraria/templates/library/base_v2_1_8/storage.py b/ix-dev/community/terraria/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/terraria/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/terraria/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/terraria/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/terraria/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/terraria/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/terraria/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/terraria/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/terraria/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/terraria/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/terraria/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/terraria/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/terraria/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/terraria/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/terraria/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/terraria/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/terraria/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/terraria/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/terraria/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/terraria/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/terraria/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/terraria/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/terraria/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/terraria/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/terraria/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/terraria/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/terraria/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/terraria/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/terraria/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/terraria/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/terraria/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/terraria/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/terraria/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/terraria/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/terraria/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/terraria/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/terraria/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/terraria/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/terraria/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/terraria/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/terraria/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/terraria/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/terraria/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/terraria/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/terraria/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/terraria/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/terraria/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/terraria/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/terraria/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/terraria/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/terraria/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/terraria/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/terraria/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/terraria/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/terraria/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/terraria/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/terraria/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/terraria/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/terraria/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/terraria/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/terraria/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/terraria/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/terraria/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/terraria/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/terraria/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/terraria/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/terraria/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/terraria/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/terraria/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/terraria/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/terraria/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/terraria/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/terraria/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/terraria/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/terraria/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/terraria/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/terraria/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/terraria/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/terraria/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/terraria/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/terraria/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/terraria/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/terraria/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/terraria/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/terraria/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/terraria/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/terraria/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/terraria/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/terraria/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/terraria/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/terraria/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/terraria/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/terraria/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/terraria/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/terraria/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/terraria/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/terraria/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/terraria/templates/library/base_v2_1_8/validations.py b/ix-dev/community/terraria/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/terraria/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/terraria/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/terraria/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/terraria/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/terraria/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/terraria/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/terraria/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/terraria/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/terraria/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/terraria/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/terraria/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/terraria/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/terraria/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/terraria/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/terraria/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/terraria/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/terraria/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/terraria/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/terraria/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/terraria/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/terraria/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/terraria/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/tftpd-hpa/app.yaml b/ix-dev/community/tftpd-hpa/app.yaml index 8dbd119c9d..114c935f97 100644 --- a/ix-dev/community/tftpd-hpa/app.yaml +++ b/ix-dev/community/tftpd-hpa/app.yaml @@ -17,8 +17,8 @@ icon: https://media.sys.truenas.net/apps/tftpd-hpa/icons/icon.png keywords: - tftp - netboot -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -36,4 +36,4 @@ sources: - https://hub.docker.com/r/ixsystems/tftpd-hpa title: TFTP Server train: community -version: 1.1.6 +version: 1.1.7 diff --git a/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/container.py b/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/ports.py b/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/configs.py b/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/container.py b/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/depends.py b/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/deps.py b/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/device.py b/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/device.py rename to ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/devices.py b/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/dns.py b/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/environment.py b/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/error.py b/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/error.py rename to ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/expose.py b/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/functions.py b/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/labels.py b/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/notes.py b/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/portal.py b/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/portals.py b/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/ports.py b/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/render.py b/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/render.py rename to ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/resources.py b/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/restart.py b/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/storage.py b/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/validations.py b/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/tftpd-hpa/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/tftpd-hpa/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/tianji/app.yaml b/ix-dev/community/tianji/app.yaml index c679d0aff8..eb72713b96 100644 --- a/ix-dev/community/tianji/app.yaml +++ b/ix-dev/community/tianji/app.yaml @@ -12,8 +12,8 @@ keywords: - monitoring - uptime - status -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -36,4 +36,4 @@ sources: - https://github.com/msgbyte/tianji title: Tianji train: community -version: 1.0.0 +version: 1.0.1 diff --git a/ix-dev/community/tianji/templates/library/base_v2_1_8/container.py b/ix-dev/community/tianji/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/tianji/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/tianji/templates/library/base_v2_1_8/ports.py b/ix-dev/community/tianji/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/tianji/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/tianji/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/tianji/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/tianji/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/tianji/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/tianji/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/tianji/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/tianji/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/tianji/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/tianji/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/tianji/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/tianji/templates/library/base_v2_1_8/configs.py b/ix-dev/community/tianji/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/tianji/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/tianji/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/tianji/templates/library/base_v2_1_9/container.py b/ix-dev/community/tianji/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/tianji/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/tianji/templates/library/base_v2_1_8/depends.py b/ix-dev/community/tianji/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/tianji/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/tianji/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/tianji/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/tianji/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/tianji/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/tianji/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/tianji/templates/library/base_v2_1_8/deps.py b/ix-dev/community/tianji/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/tianji/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/tianji/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/tianji/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/tianji/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/tianji/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/tianji/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/tianji/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/tianji/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/tianji/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/tianji/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/tianji/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/tianji/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/tianji/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/tianji/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/tianji/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/tianji/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/tianji/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/tianji/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/tianji/templates/library/base_v2_1_8/device.py b/ix-dev/community/tianji/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/tianji/templates/library/base_v2_1_8/device.py rename to ix-dev/community/tianji/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/tianji/templates/library/base_v2_1_8/devices.py b/ix-dev/community/tianji/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/tianji/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/tianji/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/tianji/templates/library/base_v2_1_8/dns.py b/ix-dev/community/tianji/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/tianji/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/tianji/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/tianji/templates/library/base_v2_1_8/environment.py b/ix-dev/community/tianji/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/tianji/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/tianji/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/tianji/templates/library/base_v2_1_8/error.py b/ix-dev/community/tianji/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/tianji/templates/library/base_v2_1_8/error.py rename to ix-dev/community/tianji/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/tianji/templates/library/base_v2_1_8/expose.py b/ix-dev/community/tianji/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/tianji/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/tianji/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/tianji/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/tianji/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/tianji/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/tianji/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/tianji/templates/library/base_v2_1_8/functions.py b/ix-dev/community/tianji/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/tianji/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/tianji/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/tianji/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/tianji/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/tianji/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/tianji/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/tianji/templates/library/base_v2_1_8/labels.py b/ix-dev/community/tianji/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/tianji/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/tianji/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/tianji/templates/library/base_v2_1_8/notes.py b/ix-dev/community/tianji/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/tianji/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/tianji/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/tianji/templates/library/base_v2_1_8/portal.py b/ix-dev/community/tianji/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/tianji/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/tianji/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/tianji/templates/library/base_v2_1_8/portals.py b/ix-dev/community/tianji/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/tianji/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/tianji/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/tianji/templates/library/base_v2_1_9/ports.py b/ix-dev/community/tianji/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/tianji/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/tianji/templates/library/base_v2_1_8/render.py b/ix-dev/community/tianji/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/tianji/templates/library/base_v2_1_8/render.py rename to ix-dev/community/tianji/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/tianji/templates/library/base_v2_1_8/resources.py b/ix-dev/community/tianji/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/tianji/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/tianji/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/tianji/templates/library/base_v2_1_8/restart.py b/ix-dev/community/tianji/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/tianji/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/tianji/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/tianji/templates/library/base_v2_1_8/storage.py b/ix-dev/community/tianji/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/tianji/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/tianji/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/tianji/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/tianji/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/tianji/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/tianji/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/tianji/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/tianji/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/tianji/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/tianji/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/tianji/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/tianji/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/tianji/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/tianji/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/tianji/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/tianji/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/tianji/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/tianji/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/tianji/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/tianji/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/tianji/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/tianji/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/tianji/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/tianji/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/tianji/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/tianji/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/tianji/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/tianji/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/tianji/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/tianji/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/tianji/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/tianji/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/tianji/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/tianji/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/tianji/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/tianji/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/tianji/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/tianji/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/tianji/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/tianji/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/tianji/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/tianji/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/tianji/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/tianji/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/tianji/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/tianji/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/tianji/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/tianji/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/tianji/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/tianji/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/tianji/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/tianji/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/tianji/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/tianji/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/tianji/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/tianji/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/tianji/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/tianji/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/tianji/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/tianji/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/tianji/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/tianji/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/tianji/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/tianji/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/tianji/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/tianji/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/tianji/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/tianji/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/tianji/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/tianji/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/tianji/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/tianji/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/tianji/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/tianji/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/tianji/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/tianji/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/tianji/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/tianji/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/tianji/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/tianji/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/tianji/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/tianji/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/tianji/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/tianji/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/tianji/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/tianji/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/tianji/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/tianji/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/tianji/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/tianji/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/tianji/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/tianji/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/tianji/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/tianji/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/tianji/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/tianji/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/tianji/templates/library/base_v2_1_8/validations.py b/ix-dev/community/tianji/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/tianji/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/tianji/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/tianji/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/tianji/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/tianji/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/tianji/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/tianji/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/tianji/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/tianji/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/tianji/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/tianji/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/tianji/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/tianji/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/tianji/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/tianji/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/tianji/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/tianji/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/tianji/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/tianji/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/tianji/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/tianji/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/tianji/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/tiny-media-manager/app.yaml b/ix-dev/community/tiny-media-manager/app.yaml index 3b8f822785..b7fc50a7b2 100644 --- a/ix-dev/community/tiny-media-manager/app.yaml +++ b/ix-dev/community/tiny-media-manager/app.yaml @@ -16,8 +16,8 @@ keywords: - media - tv-shows - movies -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -39,4 +39,4 @@ sources: - https://hub.docker.com/r/tinymediamanager/tinymediamanager title: Tiny Media Manager train: community -version: 1.1.5 +version: 1.1.6 diff --git a/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/container.py b/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/ports.py b/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/configs.py b/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/container.py b/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/depends.py b/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/deps.py b/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/device.py b/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/device.py rename to ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/devices.py b/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/dns.py b/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/environment.py b/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/error.py b/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/error.py rename to ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/expose.py b/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/functions.py b/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/labels.py b/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/notes.py b/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/portal.py b/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/portals.py b/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/ports.py b/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/render.py b/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/render.py rename to ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/resources.py b/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/restart.py b/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/storage.py b/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/validations.py b/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/tiny-media-manager/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/tiny-media-manager/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/transmission/app.yaml b/ix-dev/community/transmission/app.yaml index f32adf71bf..9719f73926 100644 --- a/ix-dev/community/transmission/app.yaml +++ b/ix-dev/community/transmission/app.yaml @@ -10,8 +10,8 @@ keywords: - media - torrent - download -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -29,4 +29,4 @@ sources: - https://transmissionbt.com/ title: Transmission train: community -version: 1.1.6 +version: 1.1.7 diff --git a/ix-dev/community/transmission/templates/library/base_v2_1_8/container.py b/ix-dev/community/transmission/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/transmission/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/transmission/templates/library/base_v2_1_8/ports.py b/ix-dev/community/transmission/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/transmission/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/transmission/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/transmission/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/transmission/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/transmission/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/transmission/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/transmission/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/transmission/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/transmission/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/transmission/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/transmission/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/transmission/templates/library/base_v2_1_8/configs.py b/ix-dev/community/transmission/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/transmission/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/transmission/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/transmission/templates/library/base_v2_1_9/container.py b/ix-dev/community/transmission/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/transmission/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/transmission/templates/library/base_v2_1_8/depends.py b/ix-dev/community/transmission/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/transmission/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/transmission/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/transmission/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/transmission/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/transmission/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/transmission/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/transmission/templates/library/base_v2_1_8/deps.py b/ix-dev/community/transmission/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/transmission/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/transmission/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/transmission/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/transmission/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/transmission/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/transmission/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/transmission/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/transmission/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/transmission/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/transmission/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/transmission/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/transmission/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/transmission/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/transmission/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/transmission/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/transmission/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/transmission/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/transmission/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/transmission/templates/library/base_v2_1_8/device.py b/ix-dev/community/transmission/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/transmission/templates/library/base_v2_1_8/device.py rename to ix-dev/community/transmission/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/transmission/templates/library/base_v2_1_8/devices.py b/ix-dev/community/transmission/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/transmission/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/transmission/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/transmission/templates/library/base_v2_1_8/dns.py b/ix-dev/community/transmission/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/transmission/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/transmission/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/transmission/templates/library/base_v2_1_8/environment.py b/ix-dev/community/transmission/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/transmission/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/transmission/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/transmission/templates/library/base_v2_1_8/error.py b/ix-dev/community/transmission/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/transmission/templates/library/base_v2_1_8/error.py rename to ix-dev/community/transmission/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/transmission/templates/library/base_v2_1_8/expose.py b/ix-dev/community/transmission/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/transmission/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/transmission/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/transmission/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/transmission/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/transmission/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/transmission/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/transmission/templates/library/base_v2_1_8/functions.py b/ix-dev/community/transmission/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/transmission/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/transmission/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/transmission/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/transmission/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/transmission/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/transmission/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/transmission/templates/library/base_v2_1_8/labels.py b/ix-dev/community/transmission/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/transmission/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/transmission/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/transmission/templates/library/base_v2_1_8/notes.py b/ix-dev/community/transmission/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/transmission/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/transmission/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/transmission/templates/library/base_v2_1_8/portal.py b/ix-dev/community/transmission/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/transmission/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/transmission/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/transmission/templates/library/base_v2_1_8/portals.py b/ix-dev/community/transmission/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/transmission/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/transmission/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/transmission/templates/library/base_v2_1_9/ports.py b/ix-dev/community/transmission/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/transmission/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/transmission/templates/library/base_v2_1_8/render.py b/ix-dev/community/transmission/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/transmission/templates/library/base_v2_1_8/render.py rename to ix-dev/community/transmission/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/transmission/templates/library/base_v2_1_8/resources.py b/ix-dev/community/transmission/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/transmission/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/transmission/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/transmission/templates/library/base_v2_1_8/restart.py b/ix-dev/community/transmission/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/transmission/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/transmission/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/transmission/templates/library/base_v2_1_8/storage.py b/ix-dev/community/transmission/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/transmission/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/transmission/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/transmission/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/transmission/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/transmission/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/transmission/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/transmission/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/transmission/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/transmission/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/transmission/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/transmission/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/transmission/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/transmission/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/transmission/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/transmission/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/transmission/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/transmission/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/transmission/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/transmission/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/transmission/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/transmission/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/transmission/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/transmission/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/transmission/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/transmission/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/transmission/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/transmission/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/transmission/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/transmission/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/transmission/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/transmission/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/transmission/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/transmission/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/transmission/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/transmission/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/transmission/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/transmission/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/transmission/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/transmission/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/transmission/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/transmission/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/transmission/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/transmission/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/transmission/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/transmission/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/transmission/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/transmission/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/transmission/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/transmission/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/transmission/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/transmission/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/transmission/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/transmission/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/transmission/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/transmission/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/transmission/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/transmission/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/transmission/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/transmission/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/transmission/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/transmission/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/transmission/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/transmission/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/transmission/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/transmission/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/transmission/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/transmission/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/transmission/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/transmission/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/transmission/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/transmission/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/transmission/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/transmission/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/transmission/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/transmission/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/transmission/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/transmission/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/transmission/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/transmission/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/transmission/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/transmission/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/transmission/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/transmission/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/transmission/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/transmission/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/transmission/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/transmission/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/transmission/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/transmission/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/transmission/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/transmission/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/transmission/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/transmission/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/transmission/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/transmission/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/transmission/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/transmission/templates/library/base_v2_1_8/validations.py b/ix-dev/community/transmission/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/transmission/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/transmission/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/transmission/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/transmission/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/transmission/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/transmission/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/transmission/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/transmission/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/transmission/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/transmission/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/transmission/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/transmission/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/transmission/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/transmission/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/transmission/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/transmission/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/transmission/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/transmission/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/transmission/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/transmission/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/transmission/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/transmission/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/twofactor-auth/app.yaml b/ix-dev/community/twofactor-auth/app.yaml index 4b5bfb9907..83dbf50477 100644 --- a/ix-dev/community/twofactor-auth/app.yaml +++ b/ix-dev/community/twofactor-auth/app.yaml @@ -11,8 +11,8 @@ keywords: - security - 2fa - otp -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -31,4 +31,4 @@ sources: - https://hub.docker.com/r/2fauth/2fauth/ title: 2FAuth train: community -version: 1.1.5 +version: 1.1.6 diff --git a/ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/container.py b/ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/ports.py b/ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/configs.py b/ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/container.py b/ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/depends.py b/ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/deps.py b/ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/device.py b/ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/device.py rename to ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/devices.py b/ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/dns.py b/ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/environment.py b/ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/error.py b/ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/error.py rename to ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/expose.py b/ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/functions.py b/ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/labels.py b/ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/notes.py b/ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/portal.py b/ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/portals.py b/ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/ports.py b/ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/render.py b/ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/render.py rename to ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/resources.py b/ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/restart.py b/ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/storage.py b/ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/validations.py b/ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/twofactor-auth/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/twofactor-auth/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/umami/app.yaml b/ix-dev/community/umami/app.yaml index 3e0e23ea56..04cbb6cd9d 100644 --- a/ix-dev/community/umami/app.yaml +++ b/ix-dev/community/umami/app.yaml @@ -9,8 +9,8 @@ icon: https://media.sys.truenas.net/apps/umami/icons/icon.svg keywords: - analytics - monitoring -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -33,4 +33,4 @@ sources: - https://github.com/umami-software/umami title: Umami train: community -version: 1.0.0 +version: 1.0.1 diff --git a/ix-dev/community/umami/templates/library/base_v2_1_8/container.py b/ix-dev/community/umami/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/umami/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/umami/templates/library/base_v2_1_8/ports.py b/ix-dev/community/umami/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/umami/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/umami/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/umami/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/umami/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/umami/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/umami/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/umami/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/umami/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/umami/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/umami/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/umami/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/umami/templates/library/base_v2_1_8/configs.py b/ix-dev/community/umami/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/umami/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/umami/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/umami/templates/library/base_v2_1_9/container.py b/ix-dev/community/umami/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/umami/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/umami/templates/library/base_v2_1_8/depends.py b/ix-dev/community/umami/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/umami/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/umami/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/umami/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/umami/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/umami/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/umami/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/umami/templates/library/base_v2_1_8/deps.py b/ix-dev/community/umami/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/umami/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/umami/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/umami/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/umami/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/umami/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/umami/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/umami/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/umami/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/umami/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/umami/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/umami/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/umami/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/umami/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/umami/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/umami/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/umami/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/umami/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/umami/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/umami/templates/library/base_v2_1_8/device.py b/ix-dev/community/umami/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/umami/templates/library/base_v2_1_8/device.py rename to ix-dev/community/umami/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/umami/templates/library/base_v2_1_8/devices.py b/ix-dev/community/umami/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/umami/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/umami/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/umami/templates/library/base_v2_1_8/dns.py b/ix-dev/community/umami/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/umami/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/umami/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/umami/templates/library/base_v2_1_8/environment.py b/ix-dev/community/umami/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/umami/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/umami/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/umami/templates/library/base_v2_1_8/error.py b/ix-dev/community/umami/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/umami/templates/library/base_v2_1_8/error.py rename to ix-dev/community/umami/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/umami/templates/library/base_v2_1_8/expose.py b/ix-dev/community/umami/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/umami/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/umami/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/umami/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/umami/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/umami/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/umami/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/umami/templates/library/base_v2_1_8/functions.py b/ix-dev/community/umami/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/umami/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/umami/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/umami/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/umami/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/umami/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/umami/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/umami/templates/library/base_v2_1_8/labels.py b/ix-dev/community/umami/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/umami/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/umami/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/umami/templates/library/base_v2_1_8/notes.py b/ix-dev/community/umami/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/umami/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/umami/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/umami/templates/library/base_v2_1_8/portal.py b/ix-dev/community/umami/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/umami/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/umami/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/umami/templates/library/base_v2_1_8/portals.py b/ix-dev/community/umami/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/umami/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/umami/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/umami/templates/library/base_v2_1_9/ports.py b/ix-dev/community/umami/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/umami/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/umami/templates/library/base_v2_1_8/render.py b/ix-dev/community/umami/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/umami/templates/library/base_v2_1_8/render.py rename to ix-dev/community/umami/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/umami/templates/library/base_v2_1_8/resources.py b/ix-dev/community/umami/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/umami/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/umami/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/umami/templates/library/base_v2_1_8/restart.py b/ix-dev/community/umami/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/umami/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/umami/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/umami/templates/library/base_v2_1_8/storage.py b/ix-dev/community/umami/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/umami/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/umami/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/umami/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/umami/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/umami/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/umami/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/umami/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/umami/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/umami/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/umami/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/umami/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/umami/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/umami/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/umami/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/umami/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/umami/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/umami/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/umami/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/umami/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/umami/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/umami/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/umami/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/umami/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/umami/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/umami/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/umami/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/umami/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/umami/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/umami/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/umami/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/umami/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/umami/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/umami/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/umami/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/umami/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/umami/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/umami/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/umami/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/umami/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/umami/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/umami/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/umami/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/umami/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/umami/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/umami/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/umami/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/umami/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/umami/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/umami/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/umami/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/umami/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/umami/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/umami/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/umami/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/umami/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/umami/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/umami/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/umami/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/umami/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/umami/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/umami/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/umami/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/umami/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/umami/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/umami/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/umami/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/umami/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/umami/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/umami/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/umami/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/umami/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/umami/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/umami/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/umami/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/umami/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/umami/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/umami/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/umami/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/umami/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/umami/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/umami/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/umami/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/umami/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/umami/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/umami/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/umami/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/umami/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/umami/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/umami/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/umami/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/umami/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/umami/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/umami/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/umami/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/umami/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/umami/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/umami/templates/library/base_v2_1_8/validations.py b/ix-dev/community/umami/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/umami/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/umami/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/umami/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/umami/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/umami/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/umami/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/umami/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/umami/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/umami/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/umami/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/umami/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/umami/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/umami/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/umami/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/umami/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/umami/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/umami/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/umami/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/umami/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/umami/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/umami/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/umami/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/unifi-controller/app.yaml b/ix-dev/community/unifi-controller/app.yaml index 39f944f758..04d8348518 100644 --- a/ix-dev/community/unifi-controller/app.yaml +++ b/ix-dev/community/unifi-controller/app.yaml @@ -10,8 +10,8 @@ keywords: - controller - unifi - network -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -30,4 +30,4 @@ sources: - https://hub.docker.com/r/goofball222/unifi title: Unifi Controller train: community -version: 1.3.6 +version: 1.3.7 diff --git a/ix-dev/community/unifi-controller/templates/library/base_v2_1_8/container.py b/ix-dev/community/unifi-controller/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/unifi-controller/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/unifi-controller/templates/library/base_v2_1_8/ports.py b/ix-dev/community/unifi-controller/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/unifi-controller/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/unifi-controller/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/unifi-controller/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/unifi-controller/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/unifi-controller/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/unifi-controller/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/unifi-controller/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/unifi-controller/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/unifi-controller/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/unifi-controller/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/unifi-controller/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/unifi-controller/templates/library/base_v2_1_8/configs.py b/ix-dev/community/unifi-controller/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/unifi-controller/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/unifi-controller/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/unifi-controller/templates/library/base_v2_1_9/container.py b/ix-dev/community/unifi-controller/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/unifi-controller/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/unifi-controller/templates/library/base_v2_1_8/depends.py b/ix-dev/community/unifi-controller/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/unifi-controller/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/unifi-controller/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/unifi-controller/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/unifi-controller/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/unifi-controller/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/unifi-controller/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/unifi-controller/templates/library/base_v2_1_8/deps.py b/ix-dev/community/unifi-controller/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/unifi-controller/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/unifi-controller/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/unifi-controller/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/unifi-controller/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/unifi-controller/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/unifi-controller/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/unifi-controller/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/unifi-controller/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/unifi-controller/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/unifi-controller/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/unifi-controller/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/unifi-controller/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/unifi-controller/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/unifi-controller/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/unifi-controller/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/unifi-controller/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/unifi-controller/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/unifi-controller/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/unifi-controller/templates/library/base_v2_1_8/device.py b/ix-dev/community/unifi-controller/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/unifi-controller/templates/library/base_v2_1_8/device.py rename to ix-dev/community/unifi-controller/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/unifi-controller/templates/library/base_v2_1_8/devices.py b/ix-dev/community/unifi-controller/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/unifi-controller/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/unifi-controller/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/unifi-controller/templates/library/base_v2_1_8/dns.py b/ix-dev/community/unifi-controller/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/unifi-controller/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/unifi-controller/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/unifi-controller/templates/library/base_v2_1_8/environment.py b/ix-dev/community/unifi-controller/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/unifi-controller/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/unifi-controller/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/unifi-controller/templates/library/base_v2_1_8/error.py b/ix-dev/community/unifi-controller/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/unifi-controller/templates/library/base_v2_1_8/error.py rename to ix-dev/community/unifi-controller/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/unifi-controller/templates/library/base_v2_1_8/expose.py b/ix-dev/community/unifi-controller/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/unifi-controller/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/unifi-controller/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/unifi-controller/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/unifi-controller/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/unifi-controller/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/unifi-controller/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/unifi-controller/templates/library/base_v2_1_8/functions.py b/ix-dev/community/unifi-controller/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/unifi-controller/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/unifi-controller/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/unifi-controller/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/unifi-controller/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/unifi-controller/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/unifi-controller/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/unifi-controller/templates/library/base_v2_1_8/labels.py b/ix-dev/community/unifi-controller/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/unifi-controller/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/unifi-controller/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/unifi-controller/templates/library/base_v2_1_8/notes.py b/ix-dev/community/unifi-controller/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/unifi-controller/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/unifi-controller/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/unifi-controller/templates/library/base_v2_1_8/portal.py b/ix-dev/community/unifi-controller/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/unifi-controller/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/unifi-controller/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/unifi-controller/templates/library/base_v2_1_8/portals.py b/ix-dev/community/unifi-controller/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/unifi-controller/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/unifi-controller/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/unifi-controller/templates/library/base_v2_1_9/ports.py b/ix-dev/community/unifi-controller/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/unifi-controller/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/unifi-controller/templates/library/base_v2_1_8/render.py b/ix-dev/community/unifi-controller/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/unifi-controller/templates/library/base_v2_1_8/render.py rename to ix-dev/community/unifi-controller/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/unifi-controller/templates/library/base_v2_1_8/resources.py b/ix-dev/community/unifi-controller/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/unifi-controller/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/unifi-controller/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/unifi-controller/templates/library/base_v2_1_8/restart.py b/ix-dev/community/unifi-controller/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/unifi-controller/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/unifi-controller/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/unifi-controller/templates/library/base_v2_1_8/storage.py b/ix-dev/community/unifi-controller/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/unifi-controller/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/unifi-controller/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/unifi-controller/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/unifi-controller/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/unifi-controller/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/unifi-controller/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/unifi-controller/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/unifi-controller/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/unifi-controller/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/unifi-controller/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/unifi-controller/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/unifi-controller/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/unifi-controller/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/unifi-controller/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/unifi-controller/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/unifi-controller/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/unifi-controller/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/unifi-controller/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/unifi-controller/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/unifi-controller/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/unifi-controller/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/unifi-controller/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/unifi-controller/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/unifi-controller/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/unifi-controller/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/unifi-controller/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/unifi-controller/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/unifi-controller/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/unifi-controller/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/unifi-controller/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/unifi-controller/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/unifi-controller/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/unifi-controller/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/unifi-controller/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/unifi-controller/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/unifi-controller/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/unifi-controller/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/unifi-controller/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/unifi-controller/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/unifi-controller/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/unifi-controller/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/unifi-controller/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/unifi-controller/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/unifi-controller/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/unifi-controller/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/unifi-controller/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/unifi-controller/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/unifi-controller/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/unifi-controller/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/unifi-controller/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/unifi-controller/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/unifi-controller/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/unifi-controller/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/unifi-controller/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/unifi-controller/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/unifi-controller/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/unifi-controller/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/unifi-controller/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/unifi-controller/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/unifi-controller/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/unifi-controller/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/unifi-controller/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/unifi-controller/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/unifi-controller/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/unifi-controller/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/unifi-controller/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/unifi-controller/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/unifi-controller/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/unifi-controller/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/unifi-controller/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/unifi-controller/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/unifi-controller/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/unifi-controller/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/unifi-controller/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/unifi-controller/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/unifi-controller/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/unifi-controller/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/unifi-controller/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/unifi-controller/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/unifi-controller/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/unifi-controller/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/unifi-controller/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/unifi-controller/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/unifi-controller/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/unifi-controller/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/unifi-controller/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/unifi-controller/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/unifi-controller/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/unifi-controller/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/unifi-controller/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/unifi-controller/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/unifi-controller/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/unifi-controller/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/unifi-controller/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/unifi-controller/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/unifi-controller/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/unifi-controller/templates/library/base_v2_1_8/validations.py b/ix-dev/community/unifi-controller/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/unifi-controller/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/unifi-controller/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/unifi-controller/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/unifi-controller/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/unifi-controller/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/unifi-controller/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/unifi-controller/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/unifi-controller/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/unifi-controller/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/unifi-controller/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/unifi-controller/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/unifi-controller/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/unifi-controller/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/unifi-controller/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/unifi-controller/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/unifi-controller/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/unifi-controller/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/unifi-controller/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/unifi-controller/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/unifi-controller/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/unifi-controller/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/unifi-controller/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/unifi-protect-backup/app.yaml b/ix-dev/community/unifi-protect-backup/app.yaml index cb522b7bdb..eb439f1ecc 100644 --- a/ix-dev/community/unifi-protect-backup/app.yaml +++ b/ix-dev/community/unifi-protect-backup/app.yaml @@ -18,8 +18,8 @@ icon: https://media.sys.truenas.net/apps/unifi-protect-backup/icons/icon.svg keywords: - backup - unifi-protect -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -37,4 +37,4 @@ sources: - https://github.com/ep1cman/unifi-protect-backup/pkgs/container/unifi-protect-backup title: Unifi Protect Backup train: community -version: 1.1.5 +version: 1.1.6 diff --git a/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/container.py b/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/ports.py b/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/configs.py b/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/container.py b/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/depends.py b/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/deps.py b/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/device.py b/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/device.py rename to ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/devices.py b/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/dns.py b/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/environment.py b/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/error.py b/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/error.py rename to ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/expose.py b/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/functions.py b/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/labels.py b/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/notes.py b/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/portal.py b/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/portals.py b/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/ports.py b/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/render.py b/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/render.py rename to ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/resources.py b/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/restart.py b/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/storage.py b/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/validations.py b/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/unifi-protect-backup/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/uptime-kuma/app.yaml b/ix-dev/community/uptime-kuma/app.yaml index 6c13c66608..5a6de29e0f 100644 --- a/ix-dev/community/uptime-kuma/app.yaml +++ b/ix-dev/community/uptime-kuma/app.yaml @@ -11,8 +11,8 @@ icon: https://media.sys.truenas.net/apps/uptime-kuma/icons/icon.svg keywords: - uptime - monitor -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -31,4 +31,4 @@ sources: - https://github.com/louislam/uptime-kuma title: Uptime Kuma train: community -version: 1.0.13 +version: 1.0.14 diff --git a/ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/container.py b/ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/ports.py b/ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/configs.py b/ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/container.py b/ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/depends.py b/ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/deps.py b/ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/device.py b/ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/device.py rename to ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/devices.py b/ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/dns.py b/ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/environment.py b/ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/error.py b/ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/error.py rename to ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/expose.py b/ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/functions.py b/ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/labels.py b/ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/notes.py b/ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/portal.py b/ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/portals.py b/ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/ports.py b/ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/render.py b/ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/render.py rename to ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/resources.py b/ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/restart.py b/ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/storage.py b/ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/validations.py b/ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/uptime-kuma/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/uptime-kuma/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/vaultwarden/app.yaml b/ix-dev/community/vaultwarden/app.yaml index ca2389e8d9..2f90a2250d 100644 --- a/ix-dev/community/vaultwarden/app.yaml +++ b/ix-dev/community/vaultwarden/app.yaml @@ -10,8 +10,8 @@ icon: https://media.sys.truenas.net/apps/vaultwarden/icons/icon.png keywords: - password - manager -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -34,4 +34,4 @@ sources: - https://github.com/dani-garcia/vaultwarden title: Vaultwarden train: community -version: 1.2.7 +version: 1.2.8 diff --git a/ix-dev/community/vaultwarden/templates/library/base_v2_1_8/container.py b/ix-dev/community/vaultwarden/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/vaultwarden/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/vaultwarden/templates/library/base_v2_1_8/ports.py b/ix-dev/community/vaultwarden/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/vaultwarden/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/vaultwarden/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/vaultwarden/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/vaultwarden/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/vaultwarden/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/vaultwarden/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/vaultwarden/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/vaultwarden/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/vaultwarden/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/vaultwarden/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/vaultwarden/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/vaultwarden/templates/library/base_v2_1_8/configs.py b/ix-dev/community/vaultwarden/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/vaultwarden/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/vaultwarden/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/vaultwarden/templates/library/base_v2_1_9/container.py b/ix-dev/community/vaultwarden/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/vaultwarden/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/vaultwarden/templates/library/base_v2_1_8/depends.py b/ix-dev/community/vaultwarden/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/vaultwarden/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/vaultwarden/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/vaultwarden/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/vaultwarden/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/vaultwarden/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/vaultwarden/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/vaultwarden/templates/library/base_v2_1_8/deps.py b/ix-dev/community/vaultwarden/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/vaultwarden/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/vaultwarden/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/vaultwarden/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/vaultwarden/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/vaultwarden/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/vaultwarden/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/vaultwarden/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/vaultwarden/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/vaultwarden/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/vaultwarden/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/vaultwarden/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/vaultwarden/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/vaultwarden/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/vaultwarden/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/vaultwarden/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/vaultwarden/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/vaultwarden/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/vaultwarden/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/vaultwarden/templates/library/base_v2_1_8/device.py b/ix-dev/community/vaultwarden/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/vaultwarden/templates/library/base_v2_1_8/device.py rename to ix-dev/community/vaultwarden/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/vaultwarden/templates/library/base_v2_1_8/devices.py b/ix-dev/community/vaultwarden/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/vaultwarden/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/vaultwarden/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/vaultwarden/templates/library/base_v2_1_8/dns.py b/ix-dev/community/vaultwarden/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/vaultwarden/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/vaultwarden/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/vaultwarden/templates/library/base_v2_1_8/environment.py b/ix-dev/community/vaultwarden/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/vaultwarden/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/vaultwarden/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/vaultwarden/templates/library/base_v2_1_8/error.py b/ix-dev/community/vaultwarden/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/vaultwarden/templates/library/base_v2_1_8/error.py rename to ix-dev/community/vaultwarden/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/vaultwarden/templates/library/base_v2_1_8/expose.py b/ix-dev/community/vaultwarden/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/vaultwarden/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/vaultwarden/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/vaultwarden/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/vaultwarden/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/vaultwarden/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/vaultwarden/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/vaultwarden/templates/library/base_v2_1_8/functions.py b/ix-dev/community/vaultwarden/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/vaultwarden/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/vaultwarden/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/vaultwarden/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/vaultwarden/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/vaultwarden/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/vaultwarden/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/vaultwarden/templates/library/base_v2_1_8/labels.py b/ix-dev/community/vaultwarden/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/vaultwarden/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/vaultwarden/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/vaultwarden/templates/library/base_v2_1_8/notes.py b/ix-dev/community/vaultwarden/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/vaultwarden/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/vaultwarden/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/vaultwarden/templates/library/base_v2_1_8/portal.py b/ix-dev/community/vaultwarden/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/vaultwarden/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/vaultwarden/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/vaultwarden/templates/library/base_v2_1_8/portals.py b/ix-dev/community/vaultwarden/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/vaultwarden/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/vaultwarden/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/vaultwarden/templates/library/base_v2_1_9/ports.py b/ix-dev/community/vaultwarden/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/vaultwarden/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/vaultwarden/templates/library/base_v2_1_8/render.py b/ix-dev/community/vaultwarden/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/vaultwarden/templates/library/base_v2_1_8/render.py rename to ix-dev/community/vaultwarden/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/vaultwarden/templates/library/base_v2_1_8/resources.py b/ix-dev/community/vaultwarden/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/vaultwarden/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/vaultwarden/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/vaultwarden/templates/library/base_v2_1_8/restart.py b/ix-dev/community/vaultwarden/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/vaultwarden/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/vaultwarden/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/vaultwarden/templates/library/base_v2_1_8/storage.py b/ix-dev/community/vaultwarden/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/vaultwarden/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/vaultwarden/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/vaultwarden/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/vaultwarden/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/vaultwarden/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/vaultwarden/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/vaultwarden/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/vaultwarden/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/vaultwarden/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/vaultwarden/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/vaultwarden/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/vaultwarden/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/vaultwarden/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/vaultwarden/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/vaultwarden/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/vaultwarden/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/vaultwarden/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/vaultwarden/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/vaultwarden/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/vaultwarden/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/vaultwarden/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/vaultwarden/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/vaultwarden/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/vaultwarden/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/vaultwarden/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/vaultwarden/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/vaultwarden/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/vaultwarden/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/vaultwarden/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/vaultwarden/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/vaultwarden/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/vaultwarden/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/vaultwarden/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/vaultwarden/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/vaultwarden/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/vaultwarden/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/vaultwarden/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/vaultwarden/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/vaultwarden/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/vaultwarden/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/vaultwarden/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/vaultwarden/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/vaultwarden/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/vaultwarden/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/vaultwarden/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/vaultwarden/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/vaultwarden/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/vaultwarden/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/vaultwarden/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/vaultwarden/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/vaultwarden/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/vaultwarden/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/vaultwarden/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/vaultwarden/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/vaultwarden/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/vaultwarden/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/vaultwarden/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/vaultwarden/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/vaultwarden/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/vaultwarden/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/vaultwarden/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/vaultwarden/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/vaultwarden/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/vaultwarden/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/vaultwarden/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/vaultwarden/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/vaultwarden/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/vaultwarden/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/vaultwarden/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/vaultwarden/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/vaultwarden/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/vaultwarden/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/vaultwarden/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/vaultwarden/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/vaultwarden/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/vaultwarden/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/vaultwarden/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/vaultwarden/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/vaultwarden/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/vaultwarden/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/vaultwarden/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/vaultwarden/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/vaultwarden/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/vaultwarden/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/vaultwarden/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/vaultwarden/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/vaultwarden/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/vaultwarden/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/vaultwarden/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/vaultwarden/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/vaultwarden/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/vaultwarden/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/vaultwarden/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/vaultwarden/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/vaultwarden/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/vaultwarden/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/vaultwarden/templates/library/base_v2_1_8/validations.py b/ix-dev/community/vaultwarden/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/vaultwarden/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/vaultwarden/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/vaultwarden/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/vaultwarden/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/vaultwarden/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/vaultwarden/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/vaultwarden/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/vaultwarden/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/vaultwarden/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/vaultwarden/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/vaultwarden/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/vaultwarden/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/vaultwarden/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/vaultwarden/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/vaultwarden/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/vaultwarden/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/vaultwarden/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/vaultwarden/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/vaultwarden/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/vaultwarden/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/vaultwarden/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/vaultwarden/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/vikunja/app.yaml b/ix-dev/community/vikunja/app.yaml index b9d23c72f4..d0afa41f67 100644 --- a/ix-dev/community/vikunja/app.yaml +++ b/ix-dev/community/vikunja/app.yaml @@ -8,8 +8,8 @@ host_mounts: [] icon: https://media.sys.truenas.net/apps/vikunja/icons/icon.png keywords: - todo -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -46,4 +46,4 @@ sources: - https://vikunja.io/ title: Vikunja train: community -version: 1.4.6 +version: 1.4.7 diff --git a/ix-dev/community/vikunja/templates/library/base_v2_1_8/container.py b/ix-dev/community/vikunja/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/vikunja/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/vikunja/templates/library/base_v2_1_8/ports.py b/ix-dev/community/vikunja/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/vikunja/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/vikunja/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/vikunja/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/vikunja/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/vikunja/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/vikunja/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/vikunja/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/vikunja/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/vikunja/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/vikunja/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/vikunja/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/vikunja/templates/library/base_v2_1_8/configs.py b/ix-dev/community/vikunja/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/vikunja/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/vikunja/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/vikunja/templates/library/base_v2_1_9/container.py b/ix-dev/community/vikunja/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/vikunja/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/vikunja/templates/library/base_v2_1_8/depends.py b/ix-dev/community/vikunja/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/vikunja/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/vikunja/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/vikunja/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/vikunja/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/vikunja/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/vikunja/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/vikunja/templates/library/base_v2_1_8/deps.py b/ix-dev/community/vikunja/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/vikunja/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/vikunja/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/vikunja/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/vikunja/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/vikunja/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/vikunja/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/vikunja/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/vikunja/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/vikunja/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/vikunja/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/vikunja/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/vikunja/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/vikunja/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/vikunja/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/vikunja/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/vikunja/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/vikunja/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/vikunja/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/vikunja/templates/library/base_v2_1_8/device.py b/ix-dev/community/vikunja/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/vikunja/templates/library/base_v2_1_8/device.py rename to ix-dev/community/vikunja/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/vikunja/templates/library/base_v2_1_8/devices.py b/ix-dev/community/vikunja/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/vikunja/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/vikunja/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/vikunja/templates/library/base_v2_1_8/dns.py b/ix-dev/community/vikunja/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/vikunja/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/vikunja/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/vikunja/templates/library/base_v2_1_8/environment.py b/ix-dev/community/vikunja/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/vikunja/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/vikunja/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/vikunja/templates/library/base_v2_1_8/error.py b/ix-dev/community/vikunja/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/vikunja/templates/library/base_v2_1_8/error.py rename to ix-dev/community/vikunja/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/vikunja/templates/library/base_v2_1_8/expose.py b/ix-dev/community/vikunja/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/vikunja/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/vikunja/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/vikunja/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/vikunja/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/vikunja/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/vikunja/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/vikunja/templates/library/base_v2_1_8/functions.py b/ix-dev/community/vikunja/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/vikunja/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/vikunja/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/vikunja/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/vikunja/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/vikunja/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/vikunja/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/vikunja/templates/library/base_v2_1_8/labels.py b/ix-dev/community/vikunja/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/vikunja/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/vikunja/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/vikunja/templates/library/base_v2_1_8/notes.py b/ix-dev/community/vikunja/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/vikunja/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/vikunja/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/vikunja/templates/library/base_v2_1_8/portal.py b/ix-dev/community/vikunja/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/vikunja/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/vikunja/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/vikunja/templates/library/base_v2_1_8/portals.py b/ix-dev/community/vikunja/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/vikunja/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/vikunja/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/vikunja/templates/library/base_v2_1_9/ports.py b/ix-dev/community/vikunja/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/vikunja/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/vikunja/templates/library/base_v2_1_8/render.py b/ix-dev/community/vikunja/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/vikunja/templates/library/base_v2_1_8/render.py rename to ix-dev/community/vikunja/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/vikunja/templates/library/base_v2_1_8/resources.py b/ix-dev/community/vikunja/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/vikunja/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/vikunja/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/vikunja/templates/library/base_v2_1_8/restart.py b/ix-dev/community/vikunja/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/vikunja/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/vikunja/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/vikunja/templates/library/base_v2_1_8/storage.py b/ix-dev/community/vikunja/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/vikunja/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/vikunja/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/vikunja/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/vikunja/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/vikunja/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/vikunja/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/vikunja/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/vikunja/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/vikunja/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/vikunja/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/vikunja/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/vikunja/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/vikunja/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/vikunja/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/vikunja/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/vikunja/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/vikunja/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/vikunja/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/vikunja/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/vikunja/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/vikunja/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/vikunja/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/vikunja/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/vikunja/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/vikunja/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/vikunja/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/vikunja/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/vikunja/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/vikunja/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/vikunja/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/vikunja/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/vikunja/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/vikunja/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/vikunja/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/vikunja/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/vikunja/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/vikunja/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/vikunja/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/vikunja/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/vikunja/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/vikunja/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/vikunja/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/vikunja/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/vikunja/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/vikunja/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/vikunja/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/vikunja/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/vikunja/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/vikunja/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/vikunja/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/vikunja/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/vikunja/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/vikunja/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/vikunja/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/vikunja/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/vikunja/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/vikunja/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/vikunja/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/vikunja/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/vikunja/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/vikunja/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/vikunja/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/vikunja/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/vikunja/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/vikunja/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/vikunja/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/vikunja/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/vikunja/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/vikunja/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/vikunja/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/vikunja/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/vikunja/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/vikunja/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/vikunja/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/vikunja/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/vikunja/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/vikunja/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/vikunja/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/vikunja/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/vikunja/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/vikunja/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/vikunja/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/vikunja/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/vikunja/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/vikunja/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/vikunja/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/vikunja/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/vikunja/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/vikunja/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/vikunja/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/vikunja/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/vikunja/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/vikunja/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/vikunja/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/vikunja/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/vikunja/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/vikunja/templates/library/base_v2_1_8/validations.py b/ix-dev/community/vikunja/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/vikunja/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/vikunja/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/vikunja/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/vikunja/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/vikunja/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/vikunja/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/vikunja/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/vikunja/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/vikunja/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/vikunja/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/vikunja/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/vikunja/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/vikunja/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/vikunja/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/vikunja/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/vikunja/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/vikunja/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/vikunja/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/vikunja/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/vikunja/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/vikunja/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/vikunja/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/webdav/app.yaml b/ix-dev/community/webdav/app.yaml index 4573c9e59c..cce45b7b56 100644 --- a/ix-dev/community/webdav/app.yaml +++ b/ix-dev/community/webdav/app.yaml @@ -10,8 +10,8 @@ icon: https://media.sys.truenas.net/apps/webdav/icons/icon.png keywords: - webdav - file-sharing -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -28,4 +28,4 @@ sources: - http://www.webdav.org/ title: WebDAV train: community -version: 1.1.6 +version: 1.1.7 diff --git a/ix-dev/community/webdav/templates/library/base_v2_1_8/container.py b/ix-dev/community/webdav/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/webdav/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/webdav/templates/library/base_v2_1_8/ports.py b/ix-dev/community/webdav/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/webdav/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/webdav/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/webdav/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/webdav/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/webdav/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/webdav/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/webdav/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/webdav/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/webdav/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/webdav/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/webdav/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/webdav/templates/library/base_v2_1_8/configs.py b/ix-dev/community/webdav/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/webdav/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/webdav/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/webdav/templates/library/base_v2_1_9/container.py b/ix-dev/community/webdav/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/webdav/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/webdav/templates/library/base_v2_1_8/depends.py b/ix-dev/community/webdav/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/webdav/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/webdav/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/webdav/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/webdav/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/webdav/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/webdav/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/webdav/templates/library/base_v2_1_8/deps.py b/ix-dev/community/webdav/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/webdav/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/webdav/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/webdav/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/webdav/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/webdav/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/webdav/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/webdav/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/webdav/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/webdav/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/webdav/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/webdav/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/webdav/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/webdav/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/webdav/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/webdav/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/webdav/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/webdav/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/webdav/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/webdav/templates/library/base_v2_1_8/device.py b/ix-dev/community/webdav/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/webdav/templates/library/base_v2_1_8/device.py rename to ix-dev/community/webdav/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/webdav/templates/library/base_v2_1_8/devices.py b/ix-dev/community/webdav/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/webdav/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/webdav/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/webdav/templates/library/base_v2_1_8/dns.py b/ix-dev/community/webdav/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/webdav/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/webdav/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/webdav/templates/library/base_v2_1_8/environment.py b/ix-dev/community/webdav/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/webdav/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/webdav/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/webdav/templates/library/base_v2_1_8/error.py b/ix-dev/community/webdav/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/webdav/templates/library/base_v2_1_8/error.py rename to ix-dev/community/webdav/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/webdav/templates/library/base_v2_1_8/expose.py b/ix-dev/community/webdav/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/webdav/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/webdav/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/webdav/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/webdav/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/webdav/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/webdav/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/webdav/templates/library/base_v2_1_8/functions.py b/ix-dev/community/webdav/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/webdav/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/webdav/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/webdav/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/webdav/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/webdav/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/webdav/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/webdav/templates/library/base_v2_1_8/labels.py b/ix-dev/community/webdav/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/webdav/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/webdav/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/webdav/templates/library/base_v2_1_8/notes.py b/ix-dev/community/webdav/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/webdav/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/webdav/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/webdav/templates/library/base_v2_1_8/portal.py b/ix-dev/community/webdav/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/webdav/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/webdav/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/webdav/templates/library/base_v2_1_8/portals.py b/ix-dev/community/webdav/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/webdav/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/webdav/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/webdav/templates/library/base_v2_1_9/ports.py b/ix-dev/community/webdav/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/webdav/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/webdav/templates/library/base_v2_1_8/render.py b/ix-dev/community/webdav/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/webdav/templates/library/base_v2_1_8/render.py rename to ix-dev/community/webdav/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/webdav/templates/library/base_v2_1_8/resources.py b/ix-dev/community/webdav/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/webdav/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/webdav/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/webdav/templates/library/base_v2_1_8/restart.py b/ix-dev/community/webdav/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/webdav/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/webdav/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/webdav/templates/library/base_v2_1_8/storage.py b/ix-dev/community/webdav/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/webdav/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/webdav/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/webdav/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/webdav/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/webdav/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/webdav/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/webdav/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/webdav/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/webdav/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/webdav/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/webdav/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/webdav/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/webdav/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/webdav/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/webdav/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/webdav/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/webdav/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/webdav/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/webdav/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/webdav/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/webdav/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/webdav/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/webdav/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/webdav/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/webdav/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/webdav/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/webdav/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/webdav/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/webdav/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/webdav/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/webdav/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/webdav/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/webdav/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/webdav/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/webdav/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/webdav/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/webdav/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/webdav/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/webdav/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/webdav/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/webdav/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/webdav/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/webdav/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/webdav/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/webdav/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/webdav/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/webdav/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/webdav/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/webdav/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/webdav/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/webdav/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/webdav/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/webdav/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/webdav/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/webdav/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/webdav/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/webdav/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/webdav/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/webdav/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/webdav/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/webdav/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/webdav/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/webdav/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/webdav/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/webdav/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/webdav/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/webdav/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/webdav/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/webdav/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/webdav/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/webdav/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/webdav/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/webdav/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/webdav/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/webdav/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/webdav/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/webdav/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/webdav/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/webdav/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/webdav/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/webdav/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/webdav/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/webdav/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/webdav/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/webdav/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/webdav/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/webdav/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/webdav/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/webdav/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/webdav/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/webdav/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/webdav/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/webdav/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/webdav/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/webdav/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/webdav/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/webdav/templates/library/base_v2_1_8/validations.py b/ix-dev/community/webdav/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/webdav/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/webdav/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/webdav/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/webdav/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/webdav/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/webdav/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/webdav/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/webdav/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/webdav/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/webdav/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/webdav/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/webdav/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/webdav/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/webdav/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/webdav/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/webdav/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/webdav/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/webdav/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/webdav/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/webdav/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/webdav/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/webdav/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/whoogle/app.yaml b/ix-dev/community/whoogle/app.yaml index 2c3aaac0ae..0415c20017 100644 --- a/ix-dev/community/whoogle/app.yaml +++ b/ix-dev/community/whoogle/app.yaml @@ -9,8 +9,8 @@ icon: https://media.sys.truenas.net/apps/whoogle/icons/icon.png keywords: - search - engine -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -30,4 +30,4 @@ sources: - https://hub.docker.com/r/benbusby/whoogle-search title: Whoogle train: community -version: 1.1.5 +version: 1.1.6 diff --git a/ix-dev/community/whoogle/templates/library/base_v2_1_8/container.py b/ix-dev/community/whoogle/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/whoogle/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/whoogle/templates/library/base_v2_1_8/ports.py b/ix-dev/community/whoogle/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/whoogle/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/whoogle/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/whoogle/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/whoogle/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/whoogle/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/whoogle/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/whoogle/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/whoogle/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/whoogle/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/whoogle/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/whoogle/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/whoogle/templates/library/base_v2_1_8/configs.py b/ix-dev/community/whoogle/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/whoogle/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/whoogle/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/whoogle/templates/library/base_v2_1_9/container.py b/ix-dev/community/whoogle/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/whoogle/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/whoogle/templates/library/base_v2_1_8/depends.py b/ix-dev/community/whoogle/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/whoogle/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/whoogle/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/whoogle/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/whoogle/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/whoogle/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/whoogle/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/whoogle/templates/library/base_v2_1_8/deps.py b/ix-dev/community/whoogle/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/whoogle/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/whoogle/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/whoogle/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/whoogle/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/whoogle/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/whoogle/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/whoogle/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/whoogle/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/whoogle/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/whoogle/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/whoogle/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/whoogle/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/whoogle/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/whoogle/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/whoogle/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/whoogle/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/whoogle/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/whoogle/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/whoogle/templates/library/base_v2_1_8/device.py b/ix-dev/community/whoogle/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/whoogle/templates/library/base_v2_1_8/device.py rename to ix-dev/community/whoogle/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/whoogle/templates/library/base_v2_1_8/devices.py b/ix-dev/community/whoogle/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/whoogle/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/whoogle/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/whoogle/templates/library/base_v2_1_8/dns.py b/ix-dev/community/whoogle/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/whoogle/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/whoogle/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/whoogle/templates/library/base_v2_1_8/environment.py b/ix-dev/community/whoogle/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/whoogle/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/whoogle/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/whoogle/templates/library/base_v2_1_8/error.py b/ix-dev/community/whoogle/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/whoogle/templates/library/base_v2_1_8/error.py rename to ix-dev/community/whoogle/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/whoogle/templates/library/base_v2_1_8/expose.py b/ix-dev/community/whoogle/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/whoogle/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/whoogle/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/whoogle/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/whoogle/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/whoogle/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/whoogle/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/whoogle/templates/library/base_v2_1_8/functions.py b/ix-dev/community/whoogle/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/whoogle/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/whoogle/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/whoogle/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/whoogle/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/whoogle/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/whoogle/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/whoogle/templates/library/base_v2_1_8/labels.py b/ix-dev/community/whoogle/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/whoogle/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/whoogle/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/whoogle/templates/library/base_v2_1_8/notes.py b/ix-dev/community/whoogle/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/whoogle/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/whoogle/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/whoogle/templates/library/base_v2_1_8/portal.py b/ix-dev/community/whoogle/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/whoogle/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/whoogle/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/whoogle/templates/library/base_v2_1_8/portals.py b/ix-dev/community/whoogle/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/whoogle/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/whoogle/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/whoogle/templates/library/base_v2_1_9/ports.py b/ix-dev/community/whoogle/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/whoogle/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/whoogle/templates/library/base_v2_1_8/render.py b/ix-dev/community/whoogle/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/whoogle/templates/library/base_v2_1_8/render.py rename to ix-dev/community/whoogle/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/whoogle/templates/library/base_v2_1_8/resources.py b/ix-dev/community/whoogle/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/whoogle/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/whoogle/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/whoogle/templates/library/base_v2_1_8/restart.py b/ix-dev/community/whoogle/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/whoogle/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/whoogle/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/whoogle/templates/library/base_v2_1_8/storage.py b/ix-dev/community/whoogle/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/whoogle/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/whoogle/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/whoogle/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/whoogle/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/whoogle/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/whoogle/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/whoogle/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/whoogle/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/whoogle/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/whoogle/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/whoogle/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/whoogle/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/whoogle/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/whoogle/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/whoogle/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/whoogle/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/whoogle/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/whoogle/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/whoogle/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/whoogle/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/whoogle/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/whoogle/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/whoogle/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/whoogle/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/whoogle/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/whoogle/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/whoogle/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/whoogle/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/whoogle/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/whoogle/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/whoogle/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/whoogle/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/whoogle/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/whoogle/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/whoogle/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/whoogle/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/whoogle/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/whoogle/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/whoogle/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/whoogle/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/whoogle/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/whoogle/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/whoogle/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/whoogle/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/whoogle/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/whoogle/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/whoogle/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/whoogle/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/whoogle/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/whoogle/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/whoogle/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/whoogle/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/whoogle/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/whoogle/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/whoogle/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/whoogle/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/whoogle/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/whoogle/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/whoogle/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/whoogle/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/whoogle/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/whoogle/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/whoogle/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/whoogle/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/whoogle/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/whoogle/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/whoogle/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/whoogle/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/whoogle/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/whoogle/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/whoogle/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/whoogle/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/whoogle/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/whoogle/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/whoogle/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/whoogle/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/whoogle/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/whoogle/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/whoogle/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/whoogle/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/whoogle/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/whoogle/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/whoogle/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/whoogle/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/whoogle/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/whoogle/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/whoogle/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/whoogle/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/whoogle/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/whoogle/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/whoogle/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/whoogle/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/whoogle/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/whoogle/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/whoogle/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/whoogle/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/whoogle/templates/library/base_v2_1_8/validations.py b/ix-dev/community/whoogle/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/whoogle/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/whoogle/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/whoogle/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/whoogle/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/whoogle/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/whoogle/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/whoogle/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/whoogle/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/whoogle/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/whoogle/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/whoogle/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/whoogle/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/whoogle/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/whoogle/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/whoogle/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/whoogle/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/whoogle/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/whoogle/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/whoogle/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/whoogle/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/whoogle/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/whoogle/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/wordpress/app.yaml b/ix-dev/community/wordpress/app.yaml index edc1cc0d98..704e4bf319 100644 --- a/ix-dev/community/wordpress/app.yaml +++ b/ix-dev/community/wordpress/app.yaml @@ -11,8 +11,8 @@ icon: https://media.sys.truenas.net/apps/wordpress/icons/icon.png keywords: - cms - blog -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -38,4 +38,4 @@ sources: - https://hub.docker.com/_/wordpress title: Wordpress train: community -version: 1.1.5 +version: 1.1.6 diff --git a/ix-dev/community/wordpress/templates/library/base_v2_1_8/container.py b/ix-dev/community/wordpress/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/wordpress/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/wordpress/templates/library/base_v2_1_8/ports.py b/ix-dev/community/wordpress/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/wordpress/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/wordpress/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/wordpress/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/wordpress/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/wordpress/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/wordpress/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/wordpress/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/wordpress/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/wordpress/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/wordpress/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/wordpress/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/wordpress/templates/library/base_v2_1_8/configs.py b/ix-dev/community/wordpress/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/wordpress/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/wordpress/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/wordpress/templates/library/base_v2_1_9/container.py b/ix-dev/community/wordpress/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/wordpress/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/wordpress/templates/library/base_v2_1_8/depends.py b/ix-dev/community/wordpress/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/wordpress/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/wordpress/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/wordpress/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/wordpress/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/wordpress/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/wordpress/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/wordpress/templates/library/base_v2_1_8/deps.py b/ix-dev/community/wordpress/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/wordpress/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/wordpress/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/wordpress/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/wordpress/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/wordpress/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/wordpress/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/wordpress/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/wordpress/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/wordpress/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/wordpress/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/wordpress/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/wordpress/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/wordpress/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/wordpress/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/wordpress/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/wordpress/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/wordpress/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/wordpress/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/wordpress/templates/library/base_v2_1_8/device.py b/ix-dev/community/wordpress/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/wordpress/templates/library/base_v2_1_8/device.py rename to ix-dev/community/wordpress/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/wordpress/templates/library/base_v2_1_8/devices.py b/ix-dev/community/wordpress/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/wordpress/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/wordpress/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/wordpress/templates/library/base_v2_1_8/dns.py b/ix-dev/community/wordpress/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/wordpress/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/wordpress/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/wordpress/templates/library/base_v2_1_8/environment.py b/ix-dev/community/wordpress/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/wordpress/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/wordpress/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/wordpress/templates/library/base_v2_1_8/error.py b/ix-dev/community/wordpress/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/wordpress/templates/library/base_v2_1_8/error.py rename to ix-dev/community/wordpress/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/wordpress/templates/library/base_v2_1_8/expose.py b/ix-dev/community/wordpress/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/wordpress/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/wordpress/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/wordpress/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/wordpress/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/wordpress/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/wordpress/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/wordpress/templates/library/base_v2_1_8/functions.py b/ix-dev/community/wordpress/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/wordpress/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/wordpress/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/wordpress/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/wordpress/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/wordpress/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/wordpress/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/wordpress/templates/library/base_v2_1_8/labels.py b/ix-dev/community/wordpress/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/wordpress/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/wordpress/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/wordpress/templates/library/base_v2_1_8/notes.py b/ix-dev/community/wordpress/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/wordpress/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/wordpress/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/wordpress/templates/library/base_v2_1_8/portal.py b/ix-dev/community/wordpress/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/wordpress/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/wordpress/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/wordpress/templates/library/base_v2_1_8/portals.py b/ix-dev/community/wordpress/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/wordpress/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/wordpress/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/wordpress/templates/library/base_v2_1_9/ports.py b/ix-dev/community/wordpress/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/wordpress/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/wordpress/templates/library/base_v2_1_8/render.py b/ix-dev/community/wordpress/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/wordpress/templates/library/base_v2_1_8/render.py rename to ix-dev/community/wordpress/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/wordpress/templates/library/base_v2_1_8/resources.py b/ix-dev/community/wordpress/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/wordpress/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/wordpress/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/wordpress/templates/library/base_v2_1_8/restart.py b/ix-dev/community/wordpress/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/wordpress/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/wordpress/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/wordpress/templates/library/base_v2_1_8/storage.py b/ix-dev/community/wordpress/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/wordpress/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/wordpress/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/wordpress/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/wordpress/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/wordpress/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/wordpress/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/wordpress/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/wordpress/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/wordpress/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/wordpress/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/wordpress/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/wordpress/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/wordpress/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/wordpress/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/wordpress/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/wordpress/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/wordpress/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/wordpress/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/wordpress/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/wordpress/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/wordpress/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/wordpress/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/wordpress/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/wordpress/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/wordpress/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/wordpress/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/wordpress/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/wordpress/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/wordpress/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/wordpress/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/wordpress/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/wordpress/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/wordpress/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/wordpress/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/wordpress/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/wordpress/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/wordpress/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/wordpress/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/wordpress/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/wordpress/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/wordpress/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/wordpress/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/wordpress/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/wordpress/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/wordpress/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/wordpress/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/wordpress/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/wordpress/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/wordpress/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/wordpress/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/wordpress/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/wordpress/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/wordpress/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/wordpress/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/wordpress/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/wordpress/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/wordpress/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/wordpress/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/wordpress/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/wordpress/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/wordpress/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/wordpress/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/wordpress/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/wordpress/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/wordpress/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/wordpress/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/wordpress/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/wordpress/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/wordpress/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/wordpress/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/wordpress/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/wordpress/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/wordpress/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/wordpress/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/wordpress/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/wordpress/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/wordpress/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/wordpress/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/wordpress/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/wordpress/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/wordpress/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/wordpress/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/wordpress/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/wordpress/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/wordpress/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/wordpress/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/wordpress/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/wordpress/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/wordpress/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/wordpress/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/wordpress/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/wordpress/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/wordpress/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/wordpress/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/wordpress/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/wordpress/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/wordpress/templates/library/base_v2_1_8/validations.py b/ix-dev/community/wordpress/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/wordpress/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/wordpress/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/wordpress/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/wordpress/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/wordpress/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/wordpress/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/wordpress/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/wordpress/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/wordpress/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/wordpress/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/wordpress/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/wordpress/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/wordpress/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/wordpress/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/wordpress/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/wordpress/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/wordpress/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/wordpress/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/wordpress/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/wordpress/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/wordpress/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/wordpress/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/zerotier/app.yaml b/ix-dev/community/zerotier/app.yaml index 1eced4c04a..af8d05309c 100644 --- a/ix-dev/community/zerotier/app.yaml +++ b/ix-dev/community/zerotier/app.yaml @@ -34,8 +34,8 @@ icon: https://media.sys.truenas.net/apps/zerotier/icons/icon.png keywords: - vpn - zerotier -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -53,4 +53,4 @@ sources: - https://hub.docker.com/r/zerotier/zerotier title: Zerotier train: community -version: 1.1.6 +version: 1.1.7 diff --git a/ix-dev/community/zerotier/templates/library/base_v2_1_8/container.py b/ix-dev/community/zerotier/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/zerotier/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/zerotier/templates/library/base_v2_1_8/ports.py b/ix-dev/community/zerotier/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/zerotier/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/zerotier/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/zerotier/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/zerotier/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/zerotier/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/zerotier/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/zerotier/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/zerotier/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/zerotier/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/zerotier/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/zerotier/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/zerotier/templates/library/base_v2_1_8/configs.py b/ix-dev/community/zerotier/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/zerotier/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/zerotier/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/zerotier/templates/library/base_v2_1_9/container.py b/ix-dev/community/zerotier/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/zerotier/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/zerotier/templates/library/base_v2_1_8/depends.py b/ix-dev/community/zerotier/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/zerotier/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/zerotier/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/zerotier/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/zerotier/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/zerotier/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/zerotier/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/zerotier/templates/library/base_v2_1_8/deps.py b/ix-dev/community/zerotier/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/zerotier/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/zerotier/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/zerotier/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/zerotier/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/zerotier/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/zerotier/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/zerotier/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/zerotier/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/zerotier/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/zerotier/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/zerotier/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/zerotier/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/zerotier/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/zerotier/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/zerotier/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/zerotier/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/zerotier/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/zerotier/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/zerotier/templates/library/base_v2_1_8/device.py b/ix-dev/community/zerotier/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/zerotier/templates/library/base_v2_1_8/device.py rename to ix-dev/community/zerotier/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/zerotier/templates/library/base_v2_1_8/devices.py b/ix-dev/community/zerotier/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/zerotier/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/zerotier/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/zerotier/templates/library/base_v2_1_8/dns.py b/ix-dev/community/zerotier/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/zerotier/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/zerotier/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/zerotier/templates/library/base_v2_1_8/environment.py b/ix-dev/community/zerotier/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/zerotier/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/zerotier/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/zerotier/templates/library/base_v2_1_8/error.py b/ix-dev/community/zerotier/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/zerotier/templates/library/base_v2_1_8/error.py rename to ix-dev/community/zerotier/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/zerotier/templates/library/base_v2_1_8/expose.py b/ix-dev/community/zerotier/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/zerotier/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/zerotier/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/zerotier/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/zerotier/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/zerotier/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/zerotier/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/zerotier/templates/library/base_v2_1_8/functions.py b/ix-dev/community/zerotier/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/zerotier/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/zerotier/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/zerotier/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/zerotier/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/zerotier/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/zerotier/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/zerotier/templates/library/base_v2_1_8/labels.py b/ix-dev/community/zerotier/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/zerotier/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/zerotier/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/zerotier/templates/library/base_v2_1_8/notes.py b/ix-dev/community/zerotier/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/zerotier/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/zerotier/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/zerotier/templates/library/base_v2_1_8/portal.py b/ix-dev/community/zerotier/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/zerotier/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/zerotier/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/zerotier/templates/library/base_v2_1_8/portals.py b/ix-dev/community/zerotier/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/zerotier/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/zerotier/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/zerotier/templates/library/base_v2_1_9/ports.py b/ix-dev/community/zerotier/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/zerotier/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/zerotier/templates/library/base_v2_1_8/render.py b/ix-dev/community/zerotier/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/zerotier/templates/library/base_v2_1_8/render.py rename to ix-dev/community/zerotier/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/zerotier/templates/library/base_v2_1_8/resources.py b/ix-dev/community/zerotier/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/zerotier/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/zerotier/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/zerotier/templates/library/base_v2_1_8/restart.py b/ix-dev/community/zerotier/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/zerotier/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/zerotier/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/zerotier/templates/library/base_v2_1_8/storage.py b/ix-dev/community/zerotier/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/zerotier/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/zerotier/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/zerotier/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/zerotier/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/zerotier/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/zerotier/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/zerotier/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/zerotier/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/zerotier/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/zerotier/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/zerotier/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/zerotier/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/zerotier/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/zerotier/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/zerotier/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/zerotier/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/zerotier/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/zerotier/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/zerotier/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/zerotier/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/zerotier/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/zerotier/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/zerotier/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/zerotier/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/zerotier/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/zerotier/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/zerotier/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/zerotier/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/zerotier/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/zerotier/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/zerotier/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/zerotier/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/zerotier/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/zerotier/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/zerotier/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/zerotier/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/zerotier/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/zerotier/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/zerotier/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/zerotier/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/zerotier/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/zerotier/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/zerotier/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/zerotier/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/zerotier/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/zerotier/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/zerotier/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/zerotier/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/zerotier/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/zerotier/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/zerotier/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/zerotier/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/zerotier/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/zerotier/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/zerotier/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/zerotier/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/zerotier/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/zerotier/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/zerotier/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/zerotier/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/zerotier/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/zerotier/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/zerotier/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/zerotier/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/zerotier/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/zerotier/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/zerotier/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/zerotier/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/zerotier/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/zerotier/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/zerotier/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/zerotier/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/zerotier/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/zerotier/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/zerotier/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/zerotier/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/zerotier/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/zerotier/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/zerotier/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/zerotier/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/zerotier/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/zerotier/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/zerotier/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/zerotier/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/zerotier/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/zerotier/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/zerotier/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/zerotier/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/zerotier/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/zerotier/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/zerotier/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/zerotier/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/zerotier/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/zerotier/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/zerotier/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/zerotier/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/zerotier/templates/library/base_v2_1_8/validations.py b/ix-dev/community/zerotier/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/zerotier/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/zerotier/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/zerotier/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/zerotier/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/zerotier/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/zerotier/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/zerotier/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/zerotier/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/zerotier/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/zerotier/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/zerotier/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/zerotier/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/zerotier/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/zerotier/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/zerotier/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/zerotier/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/zerotier/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/zerotier/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/zerotier/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/zerotier/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/zerotier/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/zerotier/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/community/zigbee2mqtt/app.yaml b/ix-dev/community/zigbee2mqtt/app.yaml index e714e6c953..c3ed68d2c5 100644 --- a/ix-dev/community/zigbee2mqtt/app.yaml +++ b/ix-dev/community/zigbee2mqtt/app.yaml @@ -12,8 +12,8 @@ keywords: - zigbee - mqtt - bridge -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -31,4 +31,4 @@ sources: - https://github.com/Koenkk/zigbee2mqtt title: Zigbee2MQTT train: community -version: 1.0.3 +version: 1.0.4 diff --git a/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/container.py b/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/ports.py b/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/__init__.py b/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/__init__.py rename to ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/configs.py b/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/configs.py rename to ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/container.py b/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/depends.py b/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/depends.py rename to ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/deploy.py b/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/deploy.py rename to ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/deps.py b/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/deps.py rename to ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/device.py b/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/device.py rename to ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/devices.py b/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/devices.py rename to ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/dns.py b/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/dns.py rename to ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/environment.py b/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/environment.py rename to ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/error.py b/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/error.py rename to ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/expose.py b/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/expose.py rename to ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/formatter.py b/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/formatter.py rename to ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/functions.py b/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/functions.py rename to ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/labels.py b/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/labels.py rename to ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/notes.py b/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/notes.py rename to ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/portal.py b/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/portal.py rename to ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/portals.py b/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/portals.py rename to ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/ports.py b/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/render.py b/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/render.py rename to ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/resources.py b/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/resources.py rename to ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/restart.py b/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/restart.py rename to ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/storage.py b/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/storage.py rename to ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/sysctls.py b/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/validations.py b/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/validations.py rename to ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/volume_types.py b/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/volumes.py b/ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_8/volumes.py rename to ix-dev/community/zigbee2mqtt/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/enterprise/asigra-ds-system/app.yaml b/ix-dev/enterprise/asigra-ds-system/app.yaml index a13797209e..96cd296b35 100644 --- a/ix-dev/enterprise/asigra-ds-system/app.yaml +++ b/ix-dev/enterprise/asigra-ds-system/app.yaml @@ -19,8 +19,8 @@ keywords: - backup - restore - asigra -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -47,4 +47,4 @@ sources: - https://hub.docker.com/r/asigra/ds-system title: Asigra DS-System train: enterprise -version: 1.0.23 +version: 1.0.24 diff --git a/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/container.py b/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/ports.py b/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/__init__.py b/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/__init__.py rename to ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/configs.py b/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/configs.py rename to ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/container.py b/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/depends.py b/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/depends.py rename to ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/deploy.py b/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/deploy.py rename to ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/deps.py b/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/deps.py rename to ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/device.py b/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/device.py rename to ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/devices.py b/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/devices.py rename to ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/dns.py b/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/dns.py rename to ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/environment.py b/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/environment.py rename to ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/error.py b/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/error.py rename to ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/expose.py b/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/expose.py rename to ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/formatter.py b/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/formatter.py rename to ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/functions.py b/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/functions.py rename to ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/labels.py b/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/labels.py rename to ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/notes.py b/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/notes.py rename to ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/portal.py b/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/portal.py rename to ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/portals.py b/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/portals.py rename to ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/ports.py b/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/render.py b/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/render.py rename to ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/resources.py b/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/resources.py rename to ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/restart.py b/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/restart.py rename to ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/storage.py b/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/storage.py rename to ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/sysctls.py b/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/validations.py b/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/validations.py rename to ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/volume_types.py b/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/volumes.py b/ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_8/volumes.py rename to ix-dev/enterprise/asigra-ds-system/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/enterprise/minio/app.yaml b/ix-dev/enterprise/minio/app.yaml index 83ac81264d..77110e2fdf 100644 --- a/ix-dev/enterprise/minio/app.yaml +++ b/ix-dev/enterprise/minio/app.yaml @@ -11,8 +11,8 @@ keywords: - minio - cloud - s3 -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -31,4 +31,4 @@ sources: - https://github.com/minio/minio title: MinIO train: enterprise -version: 1.2.7 +version: 1.2.8 diff --git a/ix-dev/enterprise/minio/templates/library/base_v2_1_8/container.py b/ix-dev/enterprise/minio/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/enterprise/minio/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/enterprise/minio/templates/library/base_v2_1_8/ports.py b/ix-dev/enterprise/minio/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/enterprise/minio/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/enterprise/minio/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/enterprise/minio/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/enterprise/minio/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/enterprise/minio/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/enterprise/minio/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/enterprise/minio/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/enterprise/minio/templates/library/base_v2_1_8/__init__.py b/ix-dev/enterprise/minio/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/enterprise/minio/templates/library/base_v2_1_8/__init__.py rename to ix-dev/enterprise/minio/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/enterprise/minio/templates/library/base_v2_1_8/configs.py b/ix-dev/enterprise/minio/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/enterprise/minio/templates/library/base_v2_1_8/configs.py rename to ix-dev/enterprise/minio/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/enterprise/minio/templates/library/base_v2_1_9/container.py b/ix-dev/enterprise/minio/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/enterprise/minio/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/enterprise/minio/templates/library/base_v2_1_8/depends.py b/ix-dev/enterprise/minio/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/enterprise/minio/templates/library/base_v2_1_8/depends.py rename to ix-dev/enterprise/minio/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/enterprise/minio/templates/library/base_v2_1_8/deploy.py b/ix-dev/enterprise/minio/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/enterprise/minio/templates/library/base_v2_1_8/deploy.py rename to ix-dev/enterprise/minio/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/enterprise/minio/templates/library/base_v2_1_8/deps.py b/ix-dev/enterprise/minio/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/enterprise/minio/templates/library/base_v2_1_8/deps.py rename to ix-dev/enterprise/minio/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/enterprise/minio/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/enterprise/minio/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/enterprise/minio/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/enterprise/minio/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/enterprise/minio/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/enterprise/minio/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/enterprise/minio/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/enterprise/minio/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/enterprise/minio/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/enterprise/minio/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/enterprise/minio/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/enterprise/minio/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/enterprise/minio/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/enterprise/minio/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/enterprise/minio/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/enterprise/minio/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/enterprise/minio/templates/library/base_v2_1_8/device.py b/ix-dev/enterprise/minio/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/enterprise/minio/templates/library/base_v2_1_8/device.py rename to ix-dev/enterprise/minio/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/enterprise/minio/templates/library/base_v2_1_8/devices.py b/ix-dev/enterprise/minio/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/enterprise/minio/templates/library/base_v2_1_8/devices.py rename to ix-dev/enterprise/minio/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/enterprise/minio/templates/library/base_v2_1_8/dns.py b/ix-dev/enterprise/minio/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/enterprise/minio/templates/library/base_v2_1_8/dns.py rename to ix-dev/enterprise/minio/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/enterprise/minio/templates/library/base_v2_1_8/environment.py b/ix-dev/enterprise/minio/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/enterprise/minio/templates/library/base_v2_1_8/environment.py rename to ix-dev/enterprise/minio/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/enterprise/minio/templates/library/base_v2_1_8/error.py b/ix-dev/enterprise/minio/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/enterprise/minio/templates/library/base_v2_1_8/error.py rename to ix-dev/enterprise/minio/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/enterprise/minio/templates/library/base_v2_1_8/expose.py b/ix-dev/enterprise/minio/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/enterprise/minio/templates/library/base_v2_1_8/expose.py rename to ix-dev/enterprise/minio/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/enterprise/minio/templates/library/base_v2_1_8/formatter.py b/ix-dev/enterprise/minio/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/enterprise/minio/templates/library/base_v2_1_8/formatter.py rename to ix-dev/enterprise/minio/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/enterprise/minio/templates/library/base_v2_1_8/functions.py b/ix-dev/enterprise/minio/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/enterprise/minio/templates/library/base_v2_1_8/functions.py rename to ix-dev/enterprise/minio/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/enterprise/minio/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/enterprise/minio/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/enterprise/minio/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/enterprise/minio/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/enterprise/minio/templates/library/base_v2_1_8/labels.py b/ix-dev/enterprise/minio/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/enterprise/minio/templates/library/base_v2_1_8/labels.py rename to ix-dev/enterprise/minio/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/enterprise/minio/templates/library/base_v2_1_8/notes.py b/ix-dev/enterprise/minio/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/enterprise/minio/templates/library/base_v2_1_8/notes.py rename to ix-dev/enterprise/minio/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/enterprise/minio/templates/library/base_v2_1_8/portal.py b/ix-dev/enterprise/minio/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/enterprise/minio/templates/library/base_v2_1_8/portal.py rename to ix-dev/enterprise/minio/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/enterprise/minio/templates/library/base_v2_1_8/portals.py b/ix-dev/enterprise/minio/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/enterprise/minio/templates/library/base_v2_1_8/portals.py rename to ix-dev/enterprise/minio/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/enterprise/minio/templates/library/base_v2_1_9/ports.py b/ix-dev/enterprise/minio/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/enterprise/minio/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/enterprise/minio/templates/library/base_v2_1_8/render.py b/ix-dev/enterprise/minio/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/enterprise/minio/templates/library/base_v2_1_8/render.py rename to ix-dev/enterprise/minio/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/enterprise/minio/templates/library/base_v2_1_8/resources.py b/ix-dev/enterprise/minio/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/enterprise/minio/templates/library/base_v2_1_8/resources.py rename to ix-dev/enterprise/minio/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/enterprise/minio/templates/library/base_v2_1_8/restart.py b/ix-dev/enterprise/minio/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/enterprise/minio/templates/library/base_v2_1_8/restart.py rename to ix-dev/enterprise/minio/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/enterprise/minio/templates/library/base_v2_1_8/storage.py b/ix-dev/enterprise/minio/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/enterprise/minio/templates/library/base_v2_1_8/storage.py rename to ix-dev/enterprise/minio/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/enterprise/minio/templates/library/base_v2_1_8/sysctls.py b/ix-dev/enterprise/minio/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/enterprise/minio/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/enterprise/minio/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/enterprise/minio/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/enterprise/minio/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/enterprise/minio/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/enterprise/minio/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/enterprise/minio/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/enterprise/minio/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/enterprise/minio/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/enterprise/minio/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/enterprise/minio/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/enterprise/minio/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/enterprise/minio/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/enterprise/minio/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/enterprise/minio/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/enterprise/minio/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/enterprise/minio/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/enterprise/minio/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/enterprise/minio/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/enterprise/minio/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/enterprise/minio/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/enterprise/minio/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/enterprise/minio/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/enterprise/minio/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/enterprise/minio/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/enterprise/minio/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/enterprise/minio/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/enterprise/minio/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/enterprise/minio/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/enterprise/minio/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/enterprise/minio/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/enterprise/minio/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/enterprise/minio/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/enterprise/minio/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/enterprise/minio/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/enterprise/minio/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/enterprise/minio/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/enterprise/minio/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/enterprise/minio/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/enterprise/minio/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/enterprise/minio/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/enterprise/minio/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/enterprise/minio/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/enterprise/minio/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/enterprise/minio/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/enterprise/minio/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/enterprise/minio/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/enterprise/minio/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/enterprise/minio/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/enterprise/minio/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/enterprise/minio/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/enterprise/minio/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/enterprise/minio/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/enterprise/minio/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/enterprise/minio/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/enterprise/minio/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/enterprise/minio/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/enterprise/minio/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/enterprise/minio/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/enterprise/minio/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/enterprise/minio/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/enterprise/minio/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/enterprise/minio/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/enterprise/minio/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/enterprise/minio/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/enterprise/minio/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/enterprise/minio/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/enterprise/minio/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/enterprise/minio/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/enterprise/minio/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/enterprise/minio/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/enterprise/minio/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/enterprise/minio/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/enterprise/minio/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/enterprise/minio/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/enterprise/minio/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/enterprise/minio/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/enterprise/minio/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/enterprise/minio/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/enterprise/minio/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/enterprise/minio/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/enterprise/minio/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/enterprise/minio/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/enterprise/minio/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/enterprise/minio/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/enterprise/minio/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/enterprise/minio/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/enterprise/minio/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/enterprise/minio/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/enterprise/minio/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/enterprise/minio/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/enterprise/minio/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/enterprise/minio/templates/library/base_v2_1_8/validations.py b/ix-dev/enterprise/minio/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/enterprise/minio/templates/library/base_v2_1_8/validations.py rename to ix-dev/enterprise/minio/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/enterprise/minio/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/enterprise/minio/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/enterprise/minio/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/enterprise/minio/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/enterprise/minio/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/enterprise/minio/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/enterprise/minio/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/enterprise/minio/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/enterprise/minio/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/enterprise/minio/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/enterprise/minio/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/enterprise/minio/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/enterprise/minio/templates/library/base_v2_1_8/volume_types.py b/ix-dev/enterprise/minio/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/enterprise/minio/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/enterprise/minio/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/enterprise/minio/templates/library/base_v2_1_8/volumes.py b/ix-dev/enterprise/minio/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/enterprise/minio/templates/library/base_v2_1_8/volumes.py rename to ix-dev/enterprise/minio/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/enterprise/syncthing/app.yaml b/ix-dev/enterprise/syncthing/app.yaml index d384a78a5d..33d5d84435 100644 --- a/ix-dev/enterprise/syncthing/app.yaml +++ b/ix-dev/enterprise/syncthing/app.yaml @@ -25,8 +25,8 @@ icon: https://media.sys.truenas.net/apps/syncthing/icons/icon.svg keywords: - sync - file-sharing -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -49,4 +49,4 @@ sources: - https://hub.docker.com/r/syncthing/syncthing title: Syncthing train: enterprise -version: 1.1.6 +version: 1.1.7 diff --git a/ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/container.py b/ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/ports.py b/ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/__init__.py b/ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/__init__.py rename to ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/configs.py b/ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/configs.py rename to ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/container.py b/ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/depends.py b/ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/depends.py rename to ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/deploy.py b/ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/deploy.py rename to ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/deps.py b/ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/deps.py rename to ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/device.py b/ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/device.py rename to ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/devices.py b/ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/devices.py rename to ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/dns.py b/ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/dns.py rename to ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/environment.py b/ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/environment.py rename to ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/error.py b/ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/error.py rename to ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/expose.py b/ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/expose.py rename to ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/formatter.py b/ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/formatter.py rename to ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/functions.py b/ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/functions.py rename to ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/labels.py b/ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/labels.py rename to ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/notes.py b/ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/notes.py rename to ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/portal.py b/ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/portal.py rename to ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/portals.py b/ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/portals.py rename to ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/ports.py b/ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/render.py b/ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/render.py rename to ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/resources.py b/ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/resources.py rename to ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/restart.py b/ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/restart.py rename to ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/storage.py b/ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/storage.py rename to ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/sysctls.py b/ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/validations.py b/ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/validations.py rename to ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/volume_types.py b/ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/volumes.py b/ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/enterprise/syncthing/templates/library/base_v2_1_8/volumes.py rename to ix-dev/enterprise/syncthing/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/stable/collabora/app.yaml b/ix-dev/stable/collabora/app.yaml index fd6ac40e03..d354eef78f 100644 --- a/ix-dev/stable/collabora/app.yaml +++ b/ix-dev/stable/collabora/app.yaml @@ -27,8 +27,8 @@ keywords: - office - documents - productivity -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -53,4 +53,4 @@ sources: - https://hub.docker.com/r/collabora/code title: Collabora train: stable -version: 1.2.8 +version: 1.2.9 diff --git a/ix-dev/stable/collabora/templates/library/base_v2_1_8/container.py b/ix-dev/stable/collabora/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/stable/collabora/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/stable/collabora/templates/library/base_v2_1_8/ports.py b/ix-dev/stable/collabora/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/stable/collabora/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/stable/collabora/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/stable/collabora/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/stable/collabora/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/stable/collabora/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/stable/collabora/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/stable/collabora/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/stable/collabora/templates/library/base_v2_1_8/__init__.py b/ix-dev/stable/collabora/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/stable/collabora/templates/library/base_v2_1_8/__init__.py rename to ix-dev/stable/collabora/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/stable/collabora/templates/library/base_v2_1_8/configs.py b/ix-dev/stable/collabora/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/stable/collabora/templates/library/base_v2_1_8/configs.py rename to ix-dev/stable/collabora/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/stable/collabora/templates/library/base_v2_1_9/container.py b/ix-dev/stable/collabora/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/stable/collabora/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/stable/collabora/templates/library/base_v2_1_8/depends.py b/ix-dev/stable/collabora/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/stable/collabora/templates/library/base_v2_1_8/depends.py rename to ix-dev/stable/collabora/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/stable/collabora/templates/library/base_v2_1_8/deploy.py b/ix-dev/stable/collabora/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/stable/collabora/templates/library/base_v2_1_8/deploy.py rename to ix-dev/stable/collabora/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/stable/collabora/templates/library/base_v2_1_8/deps.py b/ix-dev/stable/collabora/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/stable/collabora/templates/library/base_v2_1_8/deps.py rename to ix-dev/stable/collabora/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/stable/collabora/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/stable/collabora/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/stable/collabora/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/stable/collabora/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/stable/collabora/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/stable/collabora/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/stable/collabora/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/stable/collabora/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/stable/collabora/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/stable/collabora/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/stable/collabora/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/stable/collabora/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/stable/collabora/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/stable/collabora/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/stable/collabora/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/stable/collabora/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/stable/collabora/templates/library/base_v2_1_8/device.py b/ix-dev/stable/collabora/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/stable/collabora/templates/library/base_v2_1_8/device.py rename to ix-dev/stable/collabora/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/stable/collabora/templates/library/base_v2_1_8/devices.py b/ix-dev/stable/collabora/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/stable/collabora/templates/library/base_v2_1_8/devices.py rename to ix-dev/stable/collabora/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/stable/collabora/templates/library/base_v2_1_8/dns.py b/ix-dev/stable/collabora/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/stable/collabora/templates/library/base_v2_1_8/dns.py rename to ix-dev/stable/collabora/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/stable/collabora/templates/library/base_v2_1_8/environment.py b/ix-dev/stable/collabora/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/stable/collabora/templates/library/base_v2_1_8/environment.py rename to ix-dev/stable/collabora/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/stable/collabora/templates/library/base_v2_1_8/error.py b/ix-dev/stable/collabora/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/stable/collabora/templates/library/base_v2_1_8/error.py rename to ix-dev/stable/collabora/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/stable/collabora/templates/library/base_v2_1_8/expose.py b/ix-dev/stable/collabora/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/stable/collabora/templates/library/base_v2_1_8/expose.py rename to ix-dev/stable/collabora/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/stable/collabora/templates/library/base_v2_1_8/formatter.py b/ix-dev/stable/collabora/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/stable/collabora/templates/library/base_v2_1_8/formatter.py rename to ix-dev/stable/collabora/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/stable/collabora/templates/library/base_v2_1_8/functions.py b/ix-dev/stable/collabora/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/stable/collabora/templates/library/base_v2_1_8/functions.py rename to ix-dev/stable/collabora/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/stable/collabora/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/stable/collabora/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/stable/collabora/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/stable/collabora/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/stable/collabora/templates/library/base_v2_1_8/labels.py b/ix-dev/stable/collabora/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/stable/collabora/templates/library/base_v2_1_8/labels.py rename to ix-dev/stable/collabora/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/stable/collabora/templates/library/base_v2_1_8/notes.py b/ix-dev/stable/collabora/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/stable/collabora/templates/library/base_v2_1_8/notes.py rename to ix-dev/stable/collabora/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/stable/collabora/templates/library/base_v2_1_8/portal.py b/ix-dev/stable/collabora/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/stable/collabora/templates/library/base_v2_1_8/portal.py rename to ix-dev/stable/collabora/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/stable/collabora/templates/library/base_v2_1_8/portals.py b/ix-dev/stable/collabora/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/stable/collabora/templates/library/base_v2_1_8/portals.py rename to ix-dev/stable/collabora/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/stable/collabora/templates/library/base_v2_1_9/ports.py b/ix-dev/stable/collabora/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/stable/collabora/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/stable/collabora/templates/library/base_v2_1_8/render.py b/ix-dev/stable/collabora/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/stable/collabora/templates/library/base_v2_1_8/render.py rename to ix-dev/stable/collabora/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/stable/collabora/templates/library/base_v2_1_8/resources.py b/ix-dev/stable/collabora/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/stable/collabora/templates/library/base_v2_1_8/resources.py rename to ix-dev/stable/collabora/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/stable/collabora/templates/library/base_v2_1_8/restart.py b/ix-dev/stable/collabora/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/stable/collabora/templates/library/base_v2_1_8/restart.py rename to ix-dev/stable/collabora/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/stable/collabora/templates/library/base_v2_1_8/storage.py b/ix-dev/stable/collabora/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/stable/collabora/templates/library/base_v2_1_8/storage.py rename to ix-dev/stable/collabora/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/stable/collabora/templates/library/base_v2_1_8/sysctls.py b/ix-dev/stable/collabora/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/stable/collabora/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/stable/collabora/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/stable/collabora/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/stable/collabora/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/stable/collabora/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/stable/collabora/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/stable/collabora/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/stable/collabora/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/stable/collabora/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/stable/collabora/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/stable/collabora/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/stable/collabora/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/stable/collabora/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/stable/collabora/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/stable/collabora/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/stable/collabora/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/stable/collabora/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/stable/collabora/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/stable/collabora/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/stable/collabora/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/stable/collabora/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/stable/collabora/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/stable/collabora/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/stable/collabora/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/stable/collabora/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/stable/collabora/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/stable/collabora/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/stable/collabora/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/stable/collabora/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/stable/collabora/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/stable/collabora/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/stable/collabora/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/stable/collabora/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/stable/collabora/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/stable/collabora/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/stable/collabora/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/stable/collabora/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/stable/collabora/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/stable/collabora/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/stable/collabora/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/stable/collabora/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/stable/collabora/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/stable/collabora/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/stable/collabora/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/stable/collabora/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/stable/collabora/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/stable/collabora/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/stable/collabora/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/stable/collabora/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/stable/collabora/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/stable/collabora/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/stable/collabora/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/stable/collabora/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/stable/collabora/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/stable/collabora/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/stable/collabora/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/stable/collabora/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/stable/collabora/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/stable/collabora/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/stable/collabora/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/stable/collabora/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/stable/collabora/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/stable/collabora/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/stable/collabora/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/stable/collabora/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/stable/collabora/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/stable/collabora/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/stable/collabora/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/stable/collabora/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/stable/collabora/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/stable/collabora/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/stable/collabora/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/stable/collabora/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/stable/collabora/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/stable/collabora/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/stable/collabora/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/stable/collabora/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/stable/collabora/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/stable/collabora/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/stable/collabora/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/stable/collabora/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/stable/collabora/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/stable/collabora/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/stable/collabora/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/stable/collabora/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/stable/collabora/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/stable/collabora/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/stable/collabora/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/stable/collabora/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/stable/collabora/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/stable/collabora/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/stable/collabora/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/stable/collabora/templates/library/base_v2_1_8/validations.py b/ix-dev/stable/collabora/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/stable/collabora/templates/library/base_v2_1_8/validations.py rename to ix-dev/stable/collabora/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/stable/collabora/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/stable/collabora/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/stable/collabora/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/stable/collabora/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/stable/collabora/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/stable/collabora/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/stable/collabora/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/stable/collabora/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/stable/collabora/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/stable/collabora/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/stable/collabora/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/stable/collabora/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/stable/collabora/templates/library/base_v2_1_8/volume_types.py b/ix-dev/stable/collabora/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/stable/collabora/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/stable/collabora/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/stable/collabora/templates/library/base_v2_1_8/volumes.py b/ix-dev/stable/collabora/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/stable/collabora/templates/library/base_v2_1_8/volumes.py rename to ix-dev/stable/collabora/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/stable/diskoverdata/app.yaml b/ix-dev/stable/diskoverdata/app.yaml index 361f34a9b2..756391bc0b 100644 --- a/ix-dev/stable/diskoverdata/app.yaml +++ b/ix-dev/stable/diskoverdata/app.yaml @@ -23,8 +23,8 @@ keywords: - monitoring - management - discovery -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -50,4 +50,4 @@ sources: - https://github.com/linuxserver/docker-diskover title: Diskover Data train: stable -version: 1.4.6 +version: 1.4.7 diff --git a/ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/container.py b/ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/ports.py b/ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/__init__.py b/ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/__init__.py rename to ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/configs.py b/ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/configs.py rename to ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/container.py b/ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/depends.py b/ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/depends.py rename to ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/deploy.py b/ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/deploy.py rename to ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/deps.py b/ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/deps.py rename to ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/device.py b/ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/device.py rename to ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/devices.py b/ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/devices.py rename to ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/dns.py b/ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/dns.py rename to ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/environment.py b/ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/environment.py rename to ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/error.py b/ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/error.py rename to ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/expose.py b/ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/expose.py rename to ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/formatter.py b/ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/formatter.py rename to ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/functions.py b/ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/functions.py rename to ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/labels.py b/ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/labels.py rename to ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/notes.py b/ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/notes.py rename to ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/portal.py b/ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/portal.py rename to ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/portals.py b/ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/portals.py rename to ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/ports.py b/ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/render.py b/ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/render.py rename to ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/resources.py b/ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/resources.py rename to ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/restart.py b/ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/restart.py rename to ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/storage.py b/ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/storage.py rename to ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/sysctls.py b/ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/validations.py b/ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/validations.py rename to ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/volume_types.py b/ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/volumes.py b/ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/stable/diskoverdata/templates/library/base_v2_1_8/volumes.py rename to ix-dev/stable/diskoverdata/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/stable/elastic-search/app.yaml b/ix-dev/stable/elastic-search/app.yaml index e67a7eee89..e4cb0c708b 100644 --- a/ix-dev/stable/elastic-search/app.yaml +++ b/ix-dev/stable/elastic-search/app.yaml @@ -10,8 +10,8 @@ icon: https://media.sys.truenas.net/apps/elastic-search/icons/icon.svg keywords: - search - elastic -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -29,4 +29,4 @@ sources: - https://www.elastic.co/guide/en/elasticsearch/reference/master/docker.html#docker-configuration-methods title: Elastic Search train: stable -version: 1.2.6 +version: 1.2.7 diff --git a/ix-dev/stable/elastic-search/templates/library/base_v2_1_8/container.py b/ix-dev/stable/elastic-search/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/stable/elastic-search/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/stable/elastic-search/templates/library/base_v2_1_8/ports.py b/ix-dev/stable/elastic-search/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/stable/elastic-search/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/stable/elastic-search/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/stable/elastic-search/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/stable/elastic-search/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/stable/elastic-search/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/stable/elastic-search/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/stable/elastic-search/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/stable/elastic-search/templates/library/base_v2_1_8/__init__.py b/ix-dev/stable/elastic-search/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/stable/elastic-search/templates/library/base_v2_1_8/__init__.py rename to ix-dev/stable/elastic-search/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/stable/elastic-search/templates/library/base_v2_1_8/configs.py b/ix-dev/stable/elastic-search/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/stable/elastic-search/templates/library/base_v2_1_8/configs.py rename to ix-dev/stable/elastic-search/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/stable/elastic-search/templates/library/base_v2_1_9/container.py b/ix-dev/stable/elastic-search/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/stable/elastic-search/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/stable/elastic-search/templates/library/base_v2_1_8/depends.py b/ix-dev/stable/elastic-search/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/stable/elastic-search/templates/library/base_v2_1_8/depends.py rename to ix-dev/stable/elastic-search/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/stable/elastic-search/templates/library/base_v2_1_8/deploy.py b/ix-dev/stable/elastic-search/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/stable/elastic-search/templates/library/base_v2_1_8/deploy.py rename to ix-dev/stable/elastic-search/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/stable/elastic-search/templates/library/base_v2_1_8/deps.py b/ix-dev/stable/elastic-search/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/stable/elastic-search/templates/library/base_v2_1_8/deps.py rename to ix-dev/stable/elastic-search/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/stable/elastic-search/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/stable/elastic-search/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/stable/elastic-search/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/stable/elastic-search/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/stable/elastic-search/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/stable/elastic-search/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/stable/elastic-search/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/stable/elastic-search/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/stable/elastic-search/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/stable/elastic-search/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/stable/elastic-search/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/stable/elastic-search/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/stable/elastic-search/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/stable/elastic-search/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/stable/elastic-search/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/stable/elastic-search/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/stable/elastic-search/templates/library/base_v2_1_8/device.py b/ix-dev/stable/elastic-search/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/stable/elastic-search/templates/library/base_v2_1_8/device.py rename to ix-dev/stable/elastic-search/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/stable/elastic-search/templates/library/base_v2_1_8/devices.py b/ix-dev/stable/elastic-search/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/stable/elastic-search/templates/library/base_v2_1_8/devices.py rename to ix-dev/stable/elastic-search/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/stable/elastic-search/templates/library/base_v2_1_8/dns.py b/ix-dev/stable/elastic-search/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/stable/elastic-search/templates/library/base_v2_1_8/dns.py rename to ix-dev/stable/elastic-search/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/stable/elastic-search/templates/library/base_v2_1_8/environment.py b/ix-dev/stable/elastic-search/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/stable/elastic-search/templates/library/base_v2_1_8/environment.py rename to ix-dev/stable/elastic-search/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/stable/elastic-search/templates/library/base_v2_1_8/error.py b/ix-dev/stable/elastic-search/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/stable/elastic-search/templates/library/base_v2_1_8/error.py rename to ix-dev/stable/elastic-search/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/stable/elastic-search/templates/library/base_v2_1_8/expose.py b/ix-dev/stable/elastic-search/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/stable/elastic-search/templates/library/base_v2_1_8/expose.py rename to ix-dev/stable/elastic-search/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/stable/elastic-search/templates/library/base_v2_1_8/formatter.py b/ix-dev/stable/elastic-search/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/stable/elastic-search/templates/library/base_v2_1_8/formatter.py rename to ix-dev/stable/elastic-search/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/stable/elastic-search/templates/library/base_v2_1_8/functions.py b/ix-dev/stable/elastic-search/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/stable/elastic-search/templates/library/base_v2_1_8/functions.py rename to ix-dev/stable/elastic-search/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/stable/elastic-search/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/stable/elastic-search/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/stable/elastic-search/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/stable/elastic-search/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/stable/elastic-search/templates/library/base_v2_1_8/labels.py b/ix-dev/stable/elastic-search/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/stable/elastic-search/templates/library/base_v2_1_8/labels.py rename to ix-dev/stable/elastic-search/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/stable/elastic-search/templates/library/base_v2_1_8/notes.py b/ix-dev/stable/elastic-search/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/stable/elastic-search/templates/library/base_v2_1_8/notes.py rename to ix-dev/stable/elastic-search/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/stable/elastic-search/templates/library/base_v2_1_8/portal.py b/ix-dev/stable/elastic-search/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/stable/elastic-search/templates/library/base_v2_1_8/portal.py rename to ix-dev/stable/elastic-search/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/stable/elastic-search/templates/library/base_v2_1_8/portals.py b/ix-dev/stable/elastic-search/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/stable/elastic-search/templates/library/base_v2_1_8/portals.py rename to ix-dev/stable/elastic-search/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/stable/elastic-search/templates/library/base_v2_1_9/ports.py b/ix-dev/stable/elastic-search/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/stable/elastic-search/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/stable/elastic-search/templates/library/base_v2_1_8/render.py b/ix-dev/stable/elastic-search/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/stable/elastic-search/templates/library/base_v2_1_8/render.py rename to ix-dev/stable/elastic-search/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/stable/elastic-search/templates/library/base_v2_1_8/resources.py b/ix-dev/stable/elastic-search/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/stable/elastic-search/templates/library/base_v2_1_8/resources.py rename to ix-dev/stable/elastic-search/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/stable/elastic-search/templates/library/base_v2_1_8/restart.py b/ix-dev/stable/elastic-search/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/stable/elastic-search/templates/library/base_v2_1_8/restart.py rename to ix-dev/stable/elastic-search/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/stable/elastic-search/templates/library/base_v2_1_8/storage.py b/ix-dev/stable/elastic-search/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/stable/elastic-search/templates/library/base_v2_1_8/storage.py rename to ix-dev/stable/elastic-search/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/stable/elastic-search/templates/library/base_v2_1_8/sysctls.py b/ix-dev/stable/elastic-search/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/stable/elastic-search/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/stable/elastic-search/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/stable/elastic-search/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/stable/elastic-search/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/stable/elastic-search/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/stable/elastic-search/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/stable/elastic-search/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/stable/elastic-search/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/stable/elastic-search/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/stable/elastic-search/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/stable/elastic-search/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/stable/elastic-search/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/stable/elastic-search/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/stable/elastic-search/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/stable/elastic-search/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/stable/elastic-search/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/stable/elastic-search/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/stable/elastic-search/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/stable/elastic-search/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/stable/elastic-search/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/stable/elastic-search/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/stable/elastic-search/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/stable/elastic-search/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/stable/elastic-search/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/stable/elastic-search/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/stable/elastic-search/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/stable/elastic-search/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/stable/elastic-search/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/stable/elastic-search/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/stable/elastic-search/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/stable/elastic-search/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/stable/elastic-search/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/stable/elastic-search/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/stable/elastic-search/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/stable/elastic-search/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/stable/elastic-search/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/stable/elastic-search/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/stable/elastic-search/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/stable/elastic-search/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/stable/elastic-search/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/stable/elastic-search/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/stable/elastic-search/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/stable/elastic-search/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/stable/elastic-search/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/stable/elastic-search/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/stable/elastic-search/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/stable/elastic-search/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/stable/elastic-search/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/stable/elastic-search/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/stable/elastic-search/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/stable/elastic-search/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/stable/elastic-search/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/stable/elastic-search/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/stable/elastic-search/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/stable/elastic-search/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/stable/elastic-search/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/stable/elastic-search/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/stable/elastic-search/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/stable/elastic-search/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/stable/elastic-search/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/stable/elastic-search/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/stable/elastic-search/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/stable/elastic-search/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/stable/elastic-search/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/stable/elastic-search/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/stable/elastic-search/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/stable/elastic-search/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/stable/elastic-search/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/stable/elastic-search/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/stable/elastic-search/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/stable/elastic-search/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/stable/elastic-search/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/stable/elastic-search/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/stable/elastic-search/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/stable/elastic-search/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/stable/elastic-search/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/stable/elastic-search/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/stable/elastic-search/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/stable/elastic-search/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/stable/elastic-search/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/stable/elastic-search/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/stable/elastic-search/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/stable/elastic-search/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/stable/elastic-search/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/stable/elastic-search/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/stable/elastic-search/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/stable/elastic-search/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/stable/elastic-search/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/stable/elastic-search/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/stable/elastic-search/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/stable/elastic-search/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/stable/elastic-search/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/stable/elastic-search/templates/library/base_v2_1_8/validations.py b/ix-dev/stable/elastic-search/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/stable/elastic-search/templates/library/base_v2_1_8/validations.py rename to ix-dev/stable/elastic-search/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/stable/elastic-search/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/stable/elastic-search/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/stable/elastic-search/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/stable/elastic-search/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/stable/elastic-search/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/stable/elastic-search/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/stable/elastic-search/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/stable/elastic-search/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/stable/elastic-search/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/stable/elastic-search/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/stable/elastic-search/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/stable/elastic-search/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/stable/elastic-search/templates/library/base_v2_1_8/volume_types.py b/ix-dev/stable/elastic-search/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/stable/elastic-search/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/stable/elastic-search/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/stable/elastic-search/templates/library/base_v2_1_8/volumes.py b/ix-dev/stable/elastic-search/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/stable/elastic-search/templates/library/base_v2_1_8/volumes.py rename to ix-dev/stable/elastic-search/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/stable/emby/app.yaml b/ix-dev/stable/emby/app.yaml index 506b414582..79bbc3e41b 100644 --- a/ix-dev/stable/emby/app.yaml +++ b/ix-dev/stable/emby/app.yaml @@ -27,8 +27,8 @@ keywords: - series - tv - streaming -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -49,4 +49,4 @@ sources: - https://github.com/truenas/charts/tree/master/charts/emby title: Emby Server train: stable -version: 1.2.11 +version: 1.2.12 diff --git a/ix-dev/stable/emby/templates/library/base_v2_1_8/container.py b/ix-dev/stable/emby/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/stable/emby/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/stable/emby/templates/library/base_v2_1_8/ports.py b/ix-dev/stable/emby/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/stable/emby/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/stable/emby/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/stable/emby/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/stable/emby/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/stable/emby/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/stable/emby/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/stable/emby/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/stable/emby/templates/library/base_v2_1_8/__init__.py b/ix-dev/stable/emby/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/stable/emby/templates/library/base_v2_1_8/__init__.py rename to ix-dev/stable/emby/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/stable/emby/templates/library/base_v2_1_8/configs.py b/ix-dev/stable/emby/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/stable/emby/templates/library/base_v2_1_8/configs.py rename to ix-dev/stable/emby/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/stable/emby/templates/library/base_v2_1_9/container.py b/ix-dev/stable/emby/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/stable/emby/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/stable/emby/templates/library/base_v2_1_8/depends.py b/ix-dev/stable/emby/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/stable/emby/templates/library/base_v2_1_8/depends.py rename to ix-dev/stable/emby/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/stable/emby/templates/library/base_v2_1_8/deploy.py b/ix-dev/stable/emby/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/stable/emby/templates/library/base_v2_1_8/deploy.py rename to ix-dev/stable/emby/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/stable/emby/templates/library/base_v2_1_8/deps.py b/ix-dev/stable/emby/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/stable/emby/templates/library/base_v2_1_8/deps.py rename to ix-dev/stable/emby/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/stable/emby/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/stable/emby/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/stable/emby/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/stable/emby/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/stable/emby/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/stable/emby/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/stable/emby/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/stable/emby/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/stable/emby/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/stable/emby/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/stable/emby/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/stable/emby/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/stable/emby/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/stable/emby/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/stable/emby/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/stable/emby/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/stable/emby/templates/library/base_v2_1_8/device.py b/ix-dev/stable/emby/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/stable/emby/templates/library/base_v2_1_8/device.py rename to ix-dev/stable/emby/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/stable/emby/templates/library/base_v2_1_8/devices.py b/ix-dev/stable/emby/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/stable/emby/templates/library/base_v2_1_8/devices.py rename to ix-dev/stable/emby/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/stable/emby/templates/library/base_v2_1_8/dns.py b/ix-dev/stable/emby/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/stable/emby/templates/library/base_v2_1_8/dns.py rename to ix-dev/stable/emby/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/stable/emby/templates/library/base_v2_1_8/environment.py b/ix-dev/stable/emby/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/stable/emby/templates/library/base_v2_1_8/environment.py rename to ix-dev/stable/emby/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/stable/emby/templates/library/base_v2_1_8/error.py b/ix-dev/stable/emby/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/stable/emby/templates/library/base_v2_1_8/error.py rename to ix-dev/stable/emby/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/stable/emby/templates/library/base_v2_1_8/expose.py b/ix-dev/stable/emby/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/stable/emby/templates/library/base_v2_1_8/expose.py rename to ix-dev/stable/emby/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/stable/emby/templates/library/base_v2_1_8/formatter.py b/ix-dev/stable/emby/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/stable/emby/templates/library/base_v2_1_8/formatter.py rename to ix-dev/stable/emby/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/stable/emby/templates/library/base_v2_1_8/functions.py b/ix-dev/stable/emby/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/stable/emby/templates/library/base_v2_1_8/functions.py rename to ix-dev/stable/emby/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/stable/emby/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/stable/emby/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/stable/emby/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/stable/emby/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/stable/emby/templates/library/base_v2_1_8/labels.py b/ix-dev/stable/emby/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/stable/emby/templates/library/base_v2_1_8/labels.py rename to ix-dev/stable/emby/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/stable/emby/templates/library/base_v2_1_8/notes.py b/ix-dev/stable/emby/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/stable/emby/templates/library/base_v2_1_8/notes.py rename to ix-dev/stable/emby/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/stable/emby/templates/library/base_v2_1_8/portal.py b/ix-dev/stable/emby/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/stable/emby/templates/library/base_v2_1_8/portal.py rename to ix-dev/stable/emby/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/stable/emby/templates/library/base_v2_1_8/portals.py b/ix-dev/stable/emby/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/stable/emby/templates/library/base_v2_1_8/portals.py rename to ix-dev/stable/emby/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/stable/emby/templates/library/base_v2_1_9/ports.py b/ix-dev/stable/emby/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/stable/emby/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/stable/emby/templates/library/base_v2_1_8/render.py b/ix-dev/stable/emby/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/stable/emby/templates/library/base_v2_1_8/render.py rename to ix-dev/stable/emby/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/stable/emby/templates/library/base_v2_1_8/resources.py b/ix-dev/stable/emby/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/stable/emby/templates/library/base_v2_1_8/resources.py rename to ix-dev/stable/emby/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/stable/emby/templates/library/base_v2_1_8/restart.py b/ix-dev/stable/emby/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/stable/emby/templates/library/base_v2_1_8/restart.py rename to ix-dev/stable/emby/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/stable/emby/templates/library/base_v2_1_8/storage.py b/ix-dev/stable/emby/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/stable/emby/templates/library/base_v2_1_8/storage.py rename to ix-dev/stable/emby/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/stable/emby/templates/library/base_v2_1_8/sysctls.py b/ix-dev/stable/emby/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/stable/emby/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/stable/emby/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/stable/emby/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/stable/emby/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/stable/emby/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/stable/emby/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/stable/emby/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/stable/emby/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/stable/emby/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/stable/emby/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/stable/emby/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/stable/emby/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/stable/emby/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/stable/emby/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/stable/emby/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/stable/emby/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/stable/emby/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/stable/emby/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/stable/emby/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/stable/emby/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/stable/emby/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/stable/emby/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/stable/emby/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/stable/emby/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/stable/emby/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/stable/emby/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/stable/emby/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/stable/emby/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/stable/emby/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/stable/emby/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/stable/emby/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/stable/emby/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/stable/emby/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/stable/emby/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/stable/emby/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/stable/emby/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/stable/emby/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/stable/emby/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/stable/emby/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/stable/emby/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/stable/emby/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/stable/emby/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/stable/emby/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/stable/emby/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/stable/emby/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/stable/emby/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/stable/emby/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/stable/emby/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/stable/emby/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/stable/emby/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/stable/emby/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/stable/emby/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/stable/emby/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/stable/emby/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/stable/emby/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/stable/emby/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/stable/emby/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/stable/emby/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/stable/emby/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/stable/emby/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/stable/emby/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/stable/emby/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/stable/emby/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/stable/emby/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/stable/emby/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/stable/emby/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/stable/emby/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/stable/emby/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/stable/emby/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/stable/emby/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/stable/emby/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/stable/emby/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/stable/emby/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/stable/emby/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/stable/emby/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/stable/emby/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/stable/emby/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/stable/emby/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/stable/emby/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/stable/emby/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/stable/emby/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/stable/emby/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/stable/emby/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/stable/emby/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/stable/emby/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/stable/emby/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/stable/emby/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/stable/emby/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/stable/emby/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/stable/emby/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/stable/emby/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/stable/emby/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/stable/emby/templates/library/base_v2_1_8/validations.py b/ix-dev/stable/emby/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/stable/emby/templates/library/base_v2_1_8/validations.py rename to ix-dev/stable/emby/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/stable/emby/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/stable/emby/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/stable/emby/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/stable/emby/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/stable/emby/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/stable/emby/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/stable/emby/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/stable/emby/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/stable/emby/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/stable/emby/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/stable/emby/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/stable/emby/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/stable/emby/templates/library/base_v2_1_8/volume_types.py b/ix-dev/stable/emby/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/stable/emby/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/stable/emby/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/stable/emby/templates/library/base_v2_1_8/volumes.py b/ix-dev/stable/emby/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/stable/emby/templates/library/base_v2_1_8/volumes.py rename to ix-dev/stable/emby/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/stable/home-assistant/app.yaml b/ix-dev/stable/home-assistant/app.yaml index 2ab1578bf8..3e6cc92360 100644 --- a/ix-dev/stable/home-assistant/app.yaml +++ b/ix-dev/stable/home-assistant/app.yaml @@ -20,8 +20,8 @@ icon: https://media.sys.truenas.net/apps/home-assistant/icons/icon.png keywords: - home-automation - assistant -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -45,4 +45,4 @@ sources: - https://github.com/truenas/charts/tree/master/charts/home-assistant title: Home Assistant train: stable -version: 1.4.13 +version: 1.4.14 diff --git a/ix-dev/stable/home-assistant/templates/library/base_v2_1_8/container.py b/ix-dev/stable/home-assistant/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/stable/home-assistant/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/stable/home-assistant/templates/library/base_v2_1_8/ports.py b/ix-dev/stable/home-assistant/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/stable/home-assistant/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/stable/home-assistant/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/stable/home-assistant/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/stable/home-assistant/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/stable/home-assistant/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/stable/home-assistant/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/stable/home-assistant/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/stable/home-assistant/templates/library/base_v2_1_8/__init__.py b/ix-dev/stable/home-assistant/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/stable/home-assistant/templates/library/base_v2_1_8/__init__.py rename to ix-dev/stable/home-assistant/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/stable/home-assistant/templates/library/base_v2_1_8/configs.py b/ix-dev/stable/home-assistant/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/stable/home-assistant/templates/library/base_v2_1_8/configs.py rename to ix-dev/stable/home-assistant/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/stable/home-assistant/templates/library/base_v2_1_9/container.py b/ix-dev/stable/home-assistant/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/stable/home-assistant/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/stable/home-assistant/templates/library/base_v2_1_8/depends.py b/ix-dev/stable/home-assistant/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/stable/home-assistant/templates/library/base_v2_1_8/depends.py rename to ix-dev/stable/home-assistant/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/stable/home-assistant/templates/library/base_v2_1_8/deploy.py b/ix-dev/stable/home-assistant/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/stable/home-assistant/templates/library/base_v2_1_8/deploy.py rename to ix-dev/stable/home-assistant/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/stable/home-assistant/templates/library/base_v2_1_8/deps.py b/ix-dev/stable/home-assistant/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/stable/home-assistant/templates/library/base_v2_1_8/deps.py rename to ix-dev/stable/home-assistant/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/stable/home-assistant/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/stable/home-assistant/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/stable/home-assistant/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/stable/home-assistant/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/stable/home-assistant/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/stable/home-assistant/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/stable/home-assistant/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/stable/home-assistant/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/stable/home-assistant/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/stable/home-assistant/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/stable/home-assistant/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/stable/home-assistant/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/stable/home-assistant/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/stable/home-assistant/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/stable/home-assistant/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/stable/home-assistant/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/stable/home-assistant/templates/library/base_v2_1_8/device.py b/ix-dev/stable/home-assistant/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/stable/home-assistant/templates/library/base_v2_1_8/device.py rename to ix-dev/stable/home-assistant/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/stable/home-assistant/templates/library/base_v2_1_8/devices.py b/ix-dev/stable/home-assistant/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/stable/home-assistant/templates/library/base_v2_1_8/devices.py rename to ix-dev/stable/home-assistant/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/stable/home-assistant/templates/library/base_v2_1_8/dns.py b/ix-dev/stable/home-assistant/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/stable/home-assistant/templates/library/base_v2_1_8/dns.py rename to ix-dev/stable/home-assistant/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/stable/home-assistant/templates/library/base_v2_1_8/environment.py b/ix-dev/stable/home-assistant/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/stable/home-assistant/templates/library/base_v2_1_8/environment.py rename to ix-dev/stable/home-assistant/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/stable/home-assistant/templates/library/base_v2_1_8/error.py b/ix-dev/stable/home-assistant/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/stable/home-assistant/templates/library/base_v2_1_8/error.py rename to ix-dev/stable/home-assistant/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/stable/home-assistant/templates/library/base_v2_1_8/expose.py b/ix-dev/stable/home-assistant/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/stable/home-assistant/templates/library/base_v2_1_8/expose.py rename to ix-dev/stable/home-assistant/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/stable/home-assistant/templates/library/base_v2_1_8/formatter.py b/ix-dev/stable/home-assistant/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/stable/home-assistant/templates/library/base_v2_1_8/formatter.py rename to ix-dev/stable/home-assistant/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/stable/home-assistant/templates/library/base_v2_1_8/functions.py b/ix-dev/stable/home-assistant/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/stable/home-assistant/templates/library/base_v2_1_8/functions.py rename to ix-dev/stable/home-assistant/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/stable/home-assistant/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/stable/home-assistant/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/stable/home-assistant/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/stable/home-assistant/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/stable/home-assistant/templates/library/base_v2_1_8/labels.py b/ix-dev/stable/home-assistant/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/stable/home-assistant/templates/library/base_v2_1_8/labels.py rename to ix-dev/stable/home-assistant/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/stable/home-assistant/templates/library/base_v2_1_8/notes.py b/ix-dev/stable/home-assistant/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/stable/home-assistant/templates/library/base_v2_1_8/notes.py rename to ix-dev/stable/home-assistant/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/stable/home-assistant/templates/library/base_v2_1_8/portal.py b/ix-dev/stable/home-assistant/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/stable/home-assistant/templates/library/base_v2_1_8/portal.py rename to ix-dev/stable/home-assistant/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/stable/home-assistant/templates/library/base_v2_1_8/portals.py b/ix-dev/stable/home-assistant/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/stable/home-assistant/templates/library/base_v2_1_8/portals.py rename to ix-dev/stable/home-assistant/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/stable/home-assistant/templates/library/base_v2_1_9/ports.py b/ix-dev/stable/home-assistant/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/stable/home-assistant/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/stable/home-assistant/templates/library/base_v2_1_8/render.py b/ix-dev/stable/home-assistant/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/stable/home-assistant/templates/library/base_v2_1_8/render.py rename to ix-dev/stable/home-assistant/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/stable/home-assistant/templates/library/base_v2_1_8/resources.py b/ix-dev/stable/home-assistant/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/stable/home-assistant/templates/library/base_v2_1_8/resources.py rename to ix-dev/stable/home-assistant/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/stable/home-assistant/templates/library/base_v2_1_8/restart.py b/ix-dev/stable/home-assistant/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/stable/home-assistant/templates/library/base_v2_1_8/restart.py rename to ix-dev/stable/home-assistant/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/stable/home-assistant/templates/library/base_v2_1_8/storage.py b/ix-dev/stable/home-assistant/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/stable/home-assistant/templates/library/base_v2_1_8/storage.py rename to ix-dev/stable/home-assistant/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/stable/home-assistant/templates/library/base_v2_1_8/sysctls.py b/ix-dev/stable/home-assistant/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/stable/home-assistant/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/stable/home-assistant/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/stable/home-assistant/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/stable/home-assistant/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/stable/home-assistant/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/stable/home-assistant/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/stable/home-assistant/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/stable/home-assistant/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/stable/home-assistant/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/stable/home-assistant/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/stable/home-assistant/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/stable/home-assistant/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/stable/home-assistant/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/stable/home-assistant/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/stable/home-assistant/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/stable/home-assistant/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/stable/home-assistant/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/stable/home-assistant/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/stable/home-assistant/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/stable/home-assistant/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/stable/home-assistant/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/stable/home-assistant/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/stable/home-assistant/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/stable/home-assistant/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/stable/home-assistant/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/stable/home-assistant/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/stable/home-assistant/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/stable/home-assistant/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/stable/home-assistant/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/stable/home-assistant/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/stable/home-assistant/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/stable/home-assistant/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/stable/home-assistant/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/stable/home-assistant/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/stable/home-assistant/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/stable/home-assistant/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/stable/home-assistant/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/stable/home-assistant/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/stable/home-assistant/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/stable/home-assistant/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/stable/home-assistant/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/stable/home-assistant/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/stable/home-assistant/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/stable/home-assistant/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/stable/home-assistant/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/stable/home-assistant/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/stable/home-assistant/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/stable/home-assistant/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/stable/home-assistant/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/stable/home-assistant/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/stable/home-assistant/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/stable/home-assistant/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/stable/home-assistant/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/stable/home-assistant/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/stable/home-assistant/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/stable/home-assistant/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/stable/home-assistant/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/stable/home-assistant/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/stable/home-assistant/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/stable/home-assistant/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/stable/home-assistant/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/stable/home-assistant/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/stable/home-assistant/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/stable/home-assistant/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/stable/home-assistant/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/stable/home-assistant/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/stable/home-assistant/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/stable/home-assistant/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/stable/home-assistant/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/stable/home-assistant/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/stable/home-assistant/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/stable/home-assistant/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/stable/home-assistant/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/stable/home-assistant/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/stable/home-assistant/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/stable/home-assistant/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/stable/home-assistant/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/stable/home-assistant/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/stable/home-assistant/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/stable/home-assistant/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/stable/home-assistant/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/stable/home-assistant/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/stable/home-assistant/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/stable/home-assistant/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/stable/home-assistant/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/stable/home-assistant/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/stable/home-assistant/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/stable/home-assistant/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/stable/home-assistant/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/stable/home-assistant/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/stable/home-assistant/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/stable/home-assistant/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/stable/home-assistant/templates/library/base_v2_1_8/validations.py b/ix-dev/stable/home-assistant/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/stable/home-assistant/templates/library/base_v2_1_8/validations.py rename to ix-dev/stable/home-assistant/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/stable/home-assistant/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/stable/home-assistant/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/stable/home-assistant/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/stable/home-assistant/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/stable/home-assistant/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/stable/home-assistant/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/stable/home-assistant/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/stable/home-assistant/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/stable/home-assistant/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/stable/home-assistant/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/stable/home-assistant/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/stable/home-assistant/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/stable/home-assistant/templates/library/base_v2_1_8/volume_types.py b/ix-dev/stable/home-assistant/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/stable/home-assistant/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/stable/home-assistant/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/stable/home-assistant/templates/library/base_v2_1_8/volumes.py b/ix-dev/stable/home-assistant/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/stable/home-assistant/templates/library/base_v2_1_8/volumes.py rename to ix-dev/stable/home-assistant/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/stable/ix-app/app.yaml b/ix-dev/stable/ix-app/app.yaml index 081caaffdb..c6af3d5534 100644 --- a/ix-dev/stable/ix-app/app.yaml +++ b/ix-dev/stable/ix-app/app.yaml @@ -7,8 +7,8 @@ home: https://www.truenas.com/ host_mounts: [] icon: https://media.sys.truenas.net/apps/ix-chart/icons/icon.webp keywords: [] -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -19,4 +19,4 @@ screenshots: [] sources: [] title: iX App train: stable -version: 1.1.7 +version: 1.1.8 diff --git a/ix-dev/stable/ix-app/templates/library/base_v2_1_8/container.py b/ix-dev/stable/ix-app/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/stable/ix-app/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/stable/ix-app/templates/library/base_v2_1_8/ports.py b/ix-dev/stable/ix-app/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/stable/ix-app/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/stable/ix-app/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/stable/ix-app/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/stable/ix-app/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/stable/ix-app/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/stable/ix-app/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/stable/ix-app/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/stable/ix-app/templates/library/base_v2_1_8/__init__.py b/ix-dev/stable/ix-app/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/stable/ix-app/templates/library/base_v2_1_8/__init__.py rename to ix-dev/stable/ix-app/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/stable/ix-app/templates/library/base_v2_1_8/configs.py b/ix-dev/stable/ix-app/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/stable/ix-app/templates/library/base_v2_1_8/configs.py rename to ix-dev/stable/ix-app/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/stable/ix-app/templates/library/base_v2_1_9/container.py b/ix-dev/stable/ix-app/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/stable/ix-app/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/stable/ix-app/templates/library/base_v2_1_8/depends.py b/ix-dev/stable/ix-app/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/stable/ix-app/templates/library/base_v2_1_8/depends.py rename to ix-dev/stable/ix-app/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/stable/ix-app/templates/library/base_v2_1_8/deploy.py b/ix-dev/stable/ix-app/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/stable/ix-app/templates/library/base_v2_1_8/deploy.py rename to ix-dev/stable/ix-app/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/stable/ix-app/templates/library/base_v2_1_8/deps.py b/ix-dev/stable/ix-app/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/stable/ix-app/templates/library/base_v2_1_8/deps.py rename to ix-dev/stable/ix-app/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/stable/ix-app/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/stable/ix-app/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/stable/ix-app/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/stable/ix-app/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/stable/ix-app/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/stable/ix-app/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/stable/ix-app/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/stable/ix-app/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/stable/ix-app/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/stable/ix-app/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/stable/ix-app/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/stable/ix-app/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/stable/ix-app/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/stable/ix-app/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/stable/ix-app/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/stable/ix-app/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/stable/ix-app/templates/library/base_v2_1_8/device.py b/ix-dev/stable/ix-app/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/stable/ix-app/templates/library/base_v2_1_8/device.py rename to ix-dev/stable/ix-app/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/stable/ix-app/templates/library/base_v2_1_8/devices.py b/ix-dev/stable/ix-app/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/stable/ix-app/templates/library/base_v2_1_8/devices.py rename to ix-dev/stable/ix-app/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/stable/ix-app/templates/library/base_v2_1_8/dns.py b/ix-dev/stable/ix-app/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/stable/ix-app/templates/library/base_v2_1_8/dns.py rename to ix-dev/stable/ix-app/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/stable/ix-app/templates/library/base_v2_1_8/environment.py b/ix-dev/stable/ix-app/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/stable/ix-app/templates/library/base_v2_1_8/environment.py rename to ix-dev/stable/ix-app/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/stable/ix-app/templates/library/base_v2_1_8/error.py b/ix-dev/stable/ix-app/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/stable/ix-app/templates/library/base_v2_1_8/error.py rename to ix-dev/stable/ix-app/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/stable/ix-app/templates/library/base_v2_1_8/expose.py b/ix-dev/stable/ix-app/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/stable/ix-app/templates/library/base_v2_1_8/expose.py rename to ix-dev/stable/ix-app/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/stable/ix-app/templates/library/base_v2_1_8/formatter.py b/ix-dev/stable/ix-app/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/stable/ix-app/templates/library/base_v2_1_8/formatter.py rename to ix-dev/stable/ix-app/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/stable/ix-app/templates/library/base_v2_1_8/functions.py b/ix-dev/stable/ix-app/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/stable/ix-app/templates/library/base_v2_1_8/functions.py rename to ix-dev/stable/ix-app/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/stable/ix-app/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/stable/ix-app/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/stable/ix-app/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/stable/ix-app/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/stable/ix-app/templates/library/base_v2_1_8/labels.py b/ix-dev/stable/ix-app/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/stable/ix-app/templates/library/base_v2_1_8/labels.py rename to ix-dev/stable/ix-app/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/stable/ix-app/templates/library/base_v2_1_8/notes.py b/ix-dev/stable/ix-app/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/stable/ix-app/templates/library/base_v2_1_8/notes.py rename to ix-dev/stable/ix-app/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/stable/ix-app/templates/library/base_v2_1_8/portal.py b/ix-dev/stable/ix-app/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/stable/ix-app/templates/library/base_v2_1_8/portal.py rename to ix-dev/stable/ix-app/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/stable/ix-app/templates/library/base_v2_1_8/portals.py b/ix-dev/stable/ix-app/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/stable/ix-app/templates/library/base_v2_1_8/portals.py rename to ix-dev/stable/ix-app/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/stable/ix-app/templates/library/base_v2_1_9/ports.py b/ix-dev/stable/ix-app/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/stable/ix-app/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/stable/ix-app/templates/library/base_v2_1_8/render.py b/ix-dev/stable/ix-app/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/stable/ix-app/templates/library/base_v2_1_8/render.py rename to ix-dev/stable/ix-app/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/stable/ix-app/templates/library/base_v2_1_8/resources.py b/ix-dev/stable/ix-app/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/stable/ix-app/templates/library/base_v2_1_8/resources.py rename to ix-dev/stable/ix-app/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/stable/ix-app/templates/library/base_v2_1_8/restart.py b/ix-dev/stable/ix-app/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/stable/ix-app/templates/library/base_v2_1_8/restart.py rename to ix-dev/stable/ix-app/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/stable/ix-app/templates/library/base_v2_1_8/storage.py b/ix-dev/stable/ix-app/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/stable/ix-app/templates/library/base_v2_1_8/storage.py rename to ix-dev/stable/ix-app/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/stable/ix-app/templates/library/base_v2_1_8/sysctls.py b/ix-dev/stable/ix-app/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/stable/ix-app/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/stable/ix-app/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/stable/ix-app/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/stable/ix-app/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/stable/ix-app/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/stable/ix-app/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/stable/ix-app/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/stable/ix-app/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/stable/ix-app/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/stable/ix-app/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/stable/ix-app/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/stable/ix-app/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/stable/ix-app/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/stable/ix-app/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/stable/ix-app/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/stable/ix-app/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/stable/ix-app/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/stable/ix-app/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/stable/ix-app/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/stable/ix-app/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/stable/ix-app/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/stable/ix-app/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/stable/ix-app/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/stable/ix-app/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/stable/ix-app/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/stable/ix-app/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/stable/ix-app/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/stable/ix-app/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/stable/ix-app/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/stable/ix-app/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/stable/ix-app/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/stable/ix-app/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/stable/ix-app/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/stable/ix-app/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/stable/ix-app/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/stable/ix-app/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/stable/ix-app/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/stable/ix-app/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/stable/ix-app/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/stable/ix-app/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/stable/ix-app/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/stable/ix-app/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/stable/ix-app/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/stable/ix-app/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/stable/ix-app/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/stable/ix-app/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/stable/ix-app/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/stable/ix-app/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/stable/ix-app/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/stable/ix-app/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/stable/ix-app/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/stable/ix-app/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/stable/ix-app/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/stable/ix-app/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/stable/ix-app/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/stable/ix-app/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/stable/ix-app/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/stable/ix-app/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/stable/ix-app/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/stable/ix-app/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/stable/ix-app/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/stable/ix-app/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/stable/ix-app/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/stable/ix-app/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/stable/ix-app/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/stable/ix-app/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/stable/ix-app/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/stable/ix-app/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/stable/ix-app/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/stable/ix-app/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/stable/ix-app/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/stable/ix-app/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/stable/ix-app/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/stable/ix-app/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/stable/ix-app/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/stable/ix-app/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/stable/ix-app/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/stable/ix-app/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/stable/ix-app/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/stable/ix-app/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/stable/ix-app/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/stable/ix-app/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/stable/ix-app/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/stable/ix-app/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/stable/ix-app/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/stable/ix-app/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/stable/ix-app/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/stable/ix-app/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/stable/ix-app/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/stable/ix-app/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/stable/ix-app/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/stable/ix-app/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/stable/ix-app/templates/library/base_v2_1_8/validations.py b/ix-dev/stable/ix-app/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/stable/ix-app/templates/library/base_v2_1_8/validations.py rename to ix-dev/stable/ix-app/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/stable/ix-app/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/stable/ix-app/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/stable/ix-app/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/stable/ix-app/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/stable/ix-app/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/stable/ix-app/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/stable/ix-app/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/stable/ix-app/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/stable/ix-app/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/stable/ix-app/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/stable/ix-app/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/stable/ix-app/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/stable/ix-app/templates/library/base_v2_1_8/volume_types.py b/ix-dev/stable/ix-app/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/stable/ix-app/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/stable/ix-app/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/stable/ix-app/templates/library/base_v2_1_8/volumes.py b/ix-dev/stable/ix-app/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/stable/ix-app/templates/library/base_v2_1_8/volumes.py rename to ix-dev/stable/ix-app/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/stable/minio/app.yaml b/ix-dev/stable/minio/app.yaml index 067a586639..b5eae2251c 100644 --- a/ix-dev/stable/minio/app.yaml +++ b/ix-dev/stable/minio/app.yaml @@ -10,8 +10,8 @@ keywords: - storage - object-storage - S3 -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -31,4 +31,4 @@ sources: - https://github.com/minio/minio title: MinIO train: stable -version: 1.2.8 +version: 1.2.9 diff --git a/ix-dev/stable/minio/templates/library/base_v2_1_8/container.py b/ix-dev/stable/minio/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/stable/minio/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/stable/minio/templates/library/base_v2_1_8/ports.py b/ix-dev/stable/minio/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/stable/minio/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/stable/minio/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/stable/minio/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/stable/minio/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/stable/minio/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/stable/minio/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/stable/minio/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/stable/minio/templates/library/base_v2_1_8/__init__.py b/ix-dev/stable/minio/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/stable/minio/templates/library/base_v2_1_8/__init__.py rename to ix-dev/stable/minio/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/stable/minio/templates/library/base_v2_1_8/configs.py b/ix-dev/stable/minio/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/stable/minio/templates/library/base_v2_1_8/configs.py rename to ix-dev/stable/minio/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/stable/minio/templates/library/base_v2_1_9/container.py b/ix-dev/stable/minio/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/stable/minio/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/stable/minio/templates/library/base_v2_1_8/depends.py b/ix-dev/stable/minio/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/stable/minio/templates/library/base_v2_1_8/depends.py rename to ix-dev/stable/minio/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/stable/minio/templates/library/base_v2_1_8/deploy.py b/ix-dev/stable/minio/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/stable/minio/templates/library/base_v2_1_8/deploy.py rename to ix-dev/stable/minio/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/stable/minio/templates/library/base_v2_1_8/deps.py b/ix-dev/stable/minio/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/stable/minio/templates/library/base_v2_1_8/deps.py rename to ix-dev/stable/minio/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/stable/minio/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/stable/minio/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/stable/minio/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/stable/minio/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/stable/minio/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/stable/minio/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/stable/minio/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/stable/minio/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/stable/minio/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/stable/minio/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/stable/minio/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/stable/minio/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/stable/minio/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/stable/minio/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/stable/minio/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/stable/minio/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/stable/minio/templates/library/base_v2_1_8/device.py b/ix-dev/stable/minio/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/stable/minio/templates/library/base_v2_1_8/device.py rename to ix-dev/stable/minio/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/stable/minio/templates/library/base_v2_1_8/devices.py b/ix-dev/stable/minio/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/stable/minio/templates/library/base_v2_1_8/devices.py rename to ix-dev/stable/minio/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/stable/minio/templates/library/base_v2_1_8/dns.py b/ix-dev/stable/minio/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/stable/minio/templates/library/base_v2_1_8/dns.py rename to ix-dev/stable/minio/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/stable/minio/templates/library/base_v2_1_8/environment.py b/ix-dev/stable/minio/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/stable/minio/templates/library/base_v2_1_8/environment.py rename to ix-dev/stable/minio/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/stable/minio/templates/library/base_v2_1_8/error.py b/ix-dev/stable/minio/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/stable/minio/templates/library/base_v2_1_8/error.py rename to ix-dev/stable/minio/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/stable/minio/templates/library/base_v2_1_8/expose.py b/ix-dev/stable/minio/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/stable/minio/templates/library/base_v2_1_8/expose.py rename to ix-dev/stable/minio/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/stable/minio/templates/library/base_v2_1_8/formatter.py b/ix-dev/stable/minio/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/stable/minio/templates/library/base_v2_1_8/formatter.py rename to ix-dev/stable/minio/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/stable/minio/templates/library/base_v2_1_8/functions.py b/ix-dev/stable/minio/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/stable/minio/templates/library/base_v2_1_8/functions.py rename to ix-dev/stable/minio/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/stable/minio/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/stable/minio/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/stable/minio/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/stable/minio/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/stable/minio/templates/library/base_v2_1_8/labels.py b/ix-dev/stable/minio/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/stable/minio/templates/library/base_v2_1_8/labels.py rename to ix-dev/stable/minio/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/stable/minio/templates/library/base_v2_1_8/notes.py b/ix-dev/stable/minio/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/stable/minio/templates/library/base_v2_1_8/notes.py rename to ix-dev/stable/minio/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/stable/minio/templates/library/base_v2_1_8/portal.py b/ix-dev/stable/minio/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/stable/minio/templates/library/base_v2_1_8/portal.py rename to ix-dev/stable/minio/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/stable/minio/templates/library/base_v2_1_8/portals.py b/ix-dev/stable/minio/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/stable/minio/templates/library/base_v2_1_8/portals.py rename to ix-dev/stable/minio/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/stable/minio/templates/library/base_v2_1_9/ports.py b/ix-dev/stable/minio/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/stable/minio/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/stable/minio/templates/library/base_v2_1_8/render.py b/ix-dev/stable/minio/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/stable/minio/templates/library/base_v2_1_8/render.py rename to ix-dev/stable/minio/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/stable/minio/templates/library/base_v2_1_8/resources.py b/ix-dev/stable/minio/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/stable/minio/templates/library/base_v2_1_8/resources.py rename to ix-dev/stable/minio/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/stable/minio/templates/library/base_v2_1_8/restart.py b/ix-dev/stable/minio/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/stable/minio/templates/library/base_v2_1_8/restart.py rename to ix-dev/stable/minio/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/stable/minio/templates/library/base_v2_1_8/storage.py b/ix-dev/stable/minio/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/stable/minio/templates/library/base_v2_1_8/storage.py rename to ix-dev/stable/minio/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/stable/minio/templates/library/base_v2_1_8/sysctls.py b/ix-dev/stable/minio/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/stable/minio/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/stable/minio/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/stable/minio/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/stable/minio/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/stable/minio/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/stable/minio/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/stable/minio/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/stable/minio/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/stable/minio/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/stable/minio/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/stable/minio/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/stable/minio/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/stable/minio/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/stable/minio/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/stable/minio/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/stable/minio/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/stable/minio/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/stable/minio/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/stable/minio/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/stable/minio/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/stable/minio/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/stable/minio/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/stable/minio/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/stable/minio/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/stable/minio/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/stable/minio/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/stable/minio/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/stable/minio/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/stable/minio/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/stable/minio/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/stable/minio/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/stable/minio/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/stable/minio/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/stable/minio/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/stable/minio/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/stable/minio/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/stable/minio/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/stable/minio/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/stable/minio/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/stable/minio/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/stable/minio/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/stable/minio/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/stable/minio/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/stable/minio/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/stable/minio/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/stable/minio/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/stable/minio/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/stable/minio/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/stable/minio/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/stable/minio/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/stable/minio/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/stable/minio/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/stable/minio/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/stable/minio/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/stable/minio/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/stable/minio/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/stable/minio/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/stable/minio/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/stable/minio/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/stable/minio/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/stable/minio/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/stable/minio/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/stable/minio/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/stable/minio/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/stable/minio/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/stable/minio/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/stable/minio/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/stable/minio/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/stable/minio/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/stable/minio/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/stable/minio/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/stable/minio/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/stable/minio/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/stable/minio/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/stable/minio/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/stable/minio/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/stable/minio/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/stable/minio/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/stable/minio/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/stable/minio/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/stable/minio/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/stable/minio/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/stable/minio/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/stable/minio/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/stable/minio/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/stable/minio/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/stable/minio/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/stable/minio/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/stable/minio/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/stable/minio/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/stable/minio/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/stable/minio/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/stable/minio/templates/library/base_v2_1_8/validations.py b/ix-dev/stable/minio/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/stable/minio/templates/library/base_v2_1_8/validations.py rename to ix-dev/stable/minio/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/stable/minio/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/stable/minio/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/stable/minio/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/stable/minio/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/stable/minio/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/stable/minio/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/stable/minio/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/stable/minio/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/stable/minio/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/stable/minio/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/stable/minio/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/stable/minio/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/stable/minio/templates/library/base_v2_1_8/volume_types.py b/ix-dev/stable/minio/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/stable/minio/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/stable/minio/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/stable/minio/templates/library/base_v2_1_8/volumes.py b/ix-dev/stable/minio/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/stable/minio/templates/library/base_v2_1_8/volumes.py rename to ix-dev/stable/minio/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/stable/netdata/app.yaml b/ix-dev/stable/netdata/app.yaml index 3b7ccd3c48..f86ab1fffe 100644 --- a/ix-dev/stable/netdata/app.yaml +++ b/ix-dev/stable/netdata/app.yaml @@ -34,8 +34,8 @@ keywords: - alerting - metric - monitoring -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -58,4 +58,4 @@ sources: - https://github.com/netdata/netdata title: Netdata train: stable -version: 1.2.7 +version: 1.2.8 diff --git a/ix-dev/stable/netdata/templates/library/base_v2_1_8/container.py b/ix-dev/stable/netdata/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/stable/netdata/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/stable/netdata/templates/library/base_v2_1_8/ports.py b/ix-dev/stable/netdata/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/stable/netdata/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/stable/netdata/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/stable/netdata/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/stable/netdata/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/stable/netdata/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/stable/netdata/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/stable/netdata/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/stable/netdata/templates/library/base_v2_1_8/__init__.py b/ix-dev/stable/netdata/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/stable/netdata/templates/library/base_v2_1_8/__init__.py rename to ix-dev/stable/netdata/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/stable/netdata/templates/library/base_v2_1_8/configs.py b/ix-dev/stable/netdata/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/stable/netdata/templates/library/base_v2_1_8/configs.py rename to ix-dev/stable/netdata/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/stable/netdata/templates/library/base_v2_1_9/container.py b/ix-dev/stable/netdata/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/stable/netdata/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/stable/netdata/templates/library/base_v2_1_8/depends.py b/ix-dev/stable/netdata/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/stable/netdata/templates/library/base_v2_1_8/depends.py rename to ix-dev/stable/netdata/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/stable/netdata/templates/library/base_v2_1_8/deploy.py b/ix-dev/stable/netdata/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/stable/netdata/templates/library/base_v2_1_8/deploy.py rename to ix-dev/stable/netdata/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/stable/netdata/templates/library/base_v2_1_8/deps.py b/ix-dev/stable/netdata/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/stable/netdata/templates/library/base_v2_1_8/deps.py rename to ix-dev/stable/netdata/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/stable/netdata/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/stable/netdata/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/stable/netdata/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/stable/netdata/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/stable/netdata/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/stable/netdata/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/stable/netdata/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/stable/netdata/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/stable/netdata/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/stable/netdata/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/stable/netdata/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/stable/netdata/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/stable/netdata/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/stable/netdata/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/stable/netdata/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/stable/netdata/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/stable/netdata/templates/library/base_v2_1_8/device.py b/ix-dev/stable/netdata/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/stable/netdata/templates/library/base_v2_1_8/device.py rename to ix-dev/stable/netdata/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/stable/netdata/templates/library/base_v2_1_8/devices.py b/ix-dev/stable/netdata/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/stable/netdata/templates/library/base_v2_1_8/devices.py rename to ix-dev/stable/netdata/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/stable/netdata/templates/library/base_v2_1_8/dns.py b/ix-dev/stable/netdata/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/stable/netdata/templates/library/base_v2_1_8/dns.py rename to ix-dev/stable/netdata/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/stable/netdata/templates/library/base_v2_1_8/environment.py b/ix-dev/stable/netdata/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/stable/netdata/templates/library/base_v2_1_8/environment.py rename to ix-dev/stable/netdata/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/stable/netdata/templates/library/base_v2_1_8/error.py b/ix-dev/stable/netdata/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/stable/netdata/templates/library/base_v2_1_8/error.py rename to ix-dev/stable/netdata/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/stable/netdata/templates/library/base_v2_1_8/expose.py b/ix-dev/stable/netdata/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/stable/netdata/templates/library/base_v2_1_8/expose.py rename to ix-dev/stable/netdata/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/stable/netdata/templates/library/base_v2_1_8/formatter.py b/ix-dev/stable/netdata/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/stable/netdata/templates/library/base_v2_1_8/formatter.py rename to ix-dev/stable/netdata/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/stable/netdata/templates/library/base_v2_1_8/functions.py b/ix-dev/stable/netdata/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/stable/netdata/templates/library/base_v2_1_8/functions.py rename to ix-dev/stable/netdata/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/stable/netdata/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/stable/netdata/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/stable/netdata/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/stable/netdata/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/stable/netdata/templates/library/base_v2_1_8/labels.py b/ix-dev/stable/netdata/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/stable/netdata/templates/library/base_v2_1_8/labels.py rename to ix-dev/stable/netdata/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/stable/netdata/templates/library/base_v2_1_8/notes.py b/ix-dev/stable/netdata/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/stable/netdata/templates/library/base_v2_1_8/notes.py rename to ix-dev/stable/netdata/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/stable/netdata/templates/library/base_v2_1_8/portal.py b/ix-dev/stable/netdata/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/stable/netdata/templates/library/base_v2_1_8/portal.py rename to ix-dev/stable/netdata/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/stable/netdata/templates/library/base_v2_1_8/portals.py b/ix-dev/stable/netdata/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/stable/netdata/templates/library/base_v2_1_8/portals.py rename to ix-dev/stable/netdata/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/stable/netdata/templates/library/base_v2_1_9/ports.py b/ix-dev/stable/netdata/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/stable/netdata/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/stable/netdata/templates/library/base_v2_1_8/render.py b/ix-dev/stable/netdata/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/stable/netdata/templates/library/base_v2_1_8/render.py rename to ix-dev/stable/netdata/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/stable/netdata/templates/library/base_v2_1_8/resources.py b/ix-dev/stable/netdata/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/stable/netdata/templates/library/base_v2_1_8/resources.py rename to ix-dev/stable/netdata/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/stable/netdata/templates/library/base_v2_1_8/restart.py b/ix-dev/stable/netdata/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/stable/netdata/templates/library/base_v2_1_8/restart.py rename to ix-dev/stable/netdata/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/stable/netdata/templates/library/base_v2_1_8/storage.py b/ix-dev/stable/netdata/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/stable/netdata/templates/library/base_v2_1_8/storage.py rename to ix-dev/stable/netdata/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/stable/netdata/templates/library/base_v2_1_8/sysctls.py b/ix-dev/stable/netdata/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/stable/netdata/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/stable/netdata/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/stable/netdata/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/stable/netdata/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/stable/netdata/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/stable/netdata/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/stable/netdata/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/stable/netdata/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/stable/netdata/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/stable/netdata/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/stable/netdata/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/stable/netdata/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/stable/netdata/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/stable/netdata/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/stable/netdata/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/stable/netdata/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/stable/netdata/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/stable/netdata/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/stable/netdata/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/stable/netdata/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/stable/netdata/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/stable/netdata/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/stable/netdata/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/stable/netdata/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/stable/netdata/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/stable/netdata/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/stable/netdata/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/stable/netdata/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/stable/netdata/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/stable/netdata/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/stable/netdata/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/stable/netdata/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/stable/netdata/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/stable/netdata/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/stable/netdata/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/stable/netdata/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/stable/netdata/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/stable/netdata/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/stable/netdata/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/stable/netdata/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/stable/netdata/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/stable/netdata/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/stable/netdata/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/stable/netdata/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/stable/netdata/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/stable/netdata/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/stable/netdata/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/stable/netdata/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/stable/netdata/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/stable/netdata/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/stable/netdata/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/stable/netdata/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/stable/netdata/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/stable/netdata/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/stable/netdata/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/stable/netdata/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/stable/netdata/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/stable/netdata/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/stable/netdata/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/stable/netdata/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/stable/netdata/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/stable/netdata/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/stable/netdata/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/stable/netdata/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/stable/netdata/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/stable/netdata/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/stable/netdata/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/stable/netdata/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/stable/netdata/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/stable/netdata/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/stable/netdata/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/stable/netdata/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/stable/netdata/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/stable/netdata/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/stable/netdata/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/stable/netdata/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/stable/netdata/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/stable/netdata/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/stable/netdata/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/stable/netdata/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/stable/netdata/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/stable/netdata/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/stable/netdata/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/stable/netdata/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/stable/netdata/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/stable/netdata/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/stable/netdata/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/stable/netdata/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/stable/netdata/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/stable/netdata/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/stable/netdata/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/stable/netdata/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/stable/netdata/templates/library/base_v2_1_8/validations.py b/ix-dev/stable/netdata/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/stable/netdata/templates/library/base_v2_1_8/validations.py rename to ix-dev/stable/netdata/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/stable/netdata/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/stable/netdata/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/stable/netdata/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/stable/netdata/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/stable/netdata/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/stable/netdata/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/stable/netdata/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/stable/netdata/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/stable/netdata/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/stable/netdata/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/stable/netdata/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/stable/netdata/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/stable/netdata/templates/library/base_v2_1_8/volume_types.py b/ix-dev/stable/netdata/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/stable/netdata/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/stable/netdata/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/stable/netdata/templates/library/base_v2_1_8/volumes.py b/ix-dev/stable/netdata/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/stable/netdata/templates/library/base_v2_1_8/volumes.py rename to ix-dev/stable/netdata/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/stable/nextcloud/app.yaml b/ix-dev/stable/nextcloud/app.yaml index bae786595c..c7a089a408 100644 --- a/ix-dev/stable/nextcloud/app.yaml +++ b/ix-dev/stable/nextcloud/app.yaml @@ -28,8 +28,8 @@ keywords: - http - web - php -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -66,4 +66,4 @@ sources: - https://github.com/truenas/charts/tree/master/charts/nextcloud title: Nextcloud train: stable -version: 1.5.12 +version: 1.5.13 diff --git a/ix-dev/stable/nextcloud/templates/library/base_v2_1_8/container.py b/ix-dev/stable/nextcloud/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/stable/nextcloud/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/stable/nextcloud/templates/library/base_v2_1_8/ports.py b/ix-dev/stable/nextcloud/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/stable/nextcloud/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/stable/nextcloud/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/stable/nextcloud/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/stable/nextcloud/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/stable/nextcloud/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/stable/nextcloud/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/stable/nextcloud/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/stable/nextcloud/templates/library/base_v2_1_8/__init__.py b/ix-dev/stable/nextcloud/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/stable/nextcloud/templates/library/base_v2_1_8/__init__.py rename to ix-dev/stable/nextcloud/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/stable/nextcloud/templates/library/base_v2_1_8/configs.py b/ix-dev/stable/nextcloud/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/stable/nextcloud/templates/library/base_v2_1_8/configs.py rename to ix-dev/stable/nextcloud/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/stable/nextcloud/templates/library/base_v2_1_9/container.py b/ix-dev/stable/nextcloud/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/stable/nextcloud/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/stable/nextcloud/templates/library/base_v2_1_8/depends.py b/ix-dev/stable/nextcloud/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/stable/nextcloud/templates/library/base_v2_1_8/depends.py rename to ix-dev/stable/nextcloud/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/stable/nextcloud/templates/library/base_v2_1_8/deploy.py b/ix-dev/stable/nextcloud/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/stable/nextcloud/templates/library/base_v2_1_8/deploy.py rename to ix-dev/stable/nextcloud/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/stable/nextcloud/templates/library/base_v2_1_8/deps.py b/ix-dev/stable/nextcloud/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/stable/nextcloud/templates/library/base_v2_1_8/deps.py rename to ix-dev/stable/nextcloud/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/stable/nextcloud/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/stable/nextcloud/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/stable/nextcloud/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/stable/nextcloud/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/stable/nextcloud/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/stable/nextcloud/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/stable/nextcloud/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/stable/nextcloud/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/stable/nextcloud/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/stable/nextcloud/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/stable/nextcloud/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/stable/nextcloud/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/stable/nextcloud/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/stable/nextcloud/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/stable/nextcloud/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/stable/nextcloud/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/stable/nextcloud/templates/library/base_v2_1_8/device.py b/ix-dev/stable/nextcloud/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/stable/nextcloud/templates/library/base_v2_1_8/device.py rename to ix-dev/stable/nextcloud/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/stable/nextcloud/templates/library/base_v2_1_8/devices.py b/ix-dev/stable/nextcloud/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/stable/nextcloud/templates/library/base_v2_1_8/devices.py rename to ix-dev/stable/nextcloud/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/stable/nextcloud/templates/library/base_v2_1_8/dns.py b/ix-dev/stable/nextcloud/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/stable/nextcloud/templates/library/base_v2_1_8/dns.py rename to ix-dev/stable/nextcloud/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/stable/nextcloud/templates/library/base_v2_1_8/environment.py b/ix-dev/stable/nextcloud/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/stable/nextcloud/templates/library/base_v2_1_8/environment.py rename to ix-dev/stable/nextcloud/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/stable/nextcloud/templates/library/base_v2_1_8/error.py b/ix-dev/stable/nextcloud/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/stable/nextcloud/templates/library/base_v2_1_8/error.py rename to ix-dev/stable/nextcloud/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/stable/nextcloud/templates/library/base_v2_1_8/expose.py b/ix-dev/stable/nextcloud/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/stable/nextcloud/templates/library/base_v2_1_8/expose.py rename to ix-dev/stable/nextcloud/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/stable/nextcloud/templates/library/base_v2_1_8/formatter.py b/ix-dev/stable/nextcloud/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/stable/nextcloud/templates/library/base_v2_1_8/formatter.py rename to ix-dev/stable/nextcloud/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/stable/nextcloud/templates/library/base_v2_1_8/functions.py b/ix-dev/stable/nextcloud/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/stable/nextcloud/templates/library/base_v2_1_8/functions.py rename to ix-dev/stable/nextcloud/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/stable/nextcloud/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/stable/nextcloud/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/stable/nextcloud/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/stable/nextcloud/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/stable/nextcloud/templates/library/base_v2_1_8/labels.py b/ix-dev/stable/nextcloud/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/stable/nextcloud/templates/library/base_v2_1_8/labels.py rename to ix-dev/stable/nextcloud/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/stable/nextcloud/templates/library/base_v2_1_8/notes.py b/ix-dev/stable/nextcloud/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/stable/nextcloud/templates/library/base_v2_1_8/notes.py rename to ix-dev/stable/nextcloud/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/stable/nextcloud/templates/library/base_v2_1_8/portal.py b/ix-dev/stable/nextcloud/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/stable/nextcloud/templates/library/base_v2_1_8/portal.py rename to ix-dev/stable/nextcloud/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/stable/nextcloud/templates/library/base_v2_1_8/portals.py b/ix-dev/stable/nextcloud/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/stable/nextcloud/templates/library/base_v2_1_8/portals.py rename to ix-dev/stable/nextcloud/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/stable/nextcloud/templates/library/base_v2_1_9/ports.py b/ix-dev/stable/nextcloud/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/stable/nextcloud/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/stable/nextcloud/templates/library/base_v2_1_8/render.py b/ix-dev/stable/nextcloud/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/stable/nextcloud/templates/library/base_v2_1_8/render.py rename to ix-dev/stable/nextcloud/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/stable/nextcloud/templates/library/base_v2_1_8/resources.py b/ix-dev/stable/nextcloud/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/stable/nextcloud/templates/library/base_v2_1_8/resources.py rename to ix-dev/stable/nextcloud/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/stable/nextcloud/templates/library/base_v2_1_8/restart.py b/ix-dev/stable/nextcloud/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/stable/nextcloud/templates/library/base_v2_1_8/restart.py rename to ix-dev/stable/nextcloud/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/stable/nextcloud/templates/library/base_v2_1_8/storage.py b/ix-dev/stable/nextcloud/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/stable/nextcloud/templates/library/base_v2_1_8/storage.py rename to ix-dev/stable/nextcloud/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/stable/nextcloud/templates/library/base_v2_1_8/sysctls.py b/ix-dev/stable/nextcloud/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/stable/nextcloud/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/stable/nextcloud/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/stable/nextcloud/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/stable/nextcloud/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/stable/nextcloud/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/stable/nextcloud/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/stable/nextcloud/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/stable/nextcloud/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/stable/nextcloud/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/stable/nextcloud/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/stable/nextcloud/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/stable/nextcloud/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/stable/nextcloud/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/stable/nextcloud/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/stable/nextcloud/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/stable/nextcloud/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/stable/nextcloud/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/stable/nextcloud/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/stable/nextcloud/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/stable/nextcloud/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/stable/nextcloud/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/stable/nextcloud/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/stable/nextcloud/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/stable/nextcloud/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/stable/nextcloud/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/stable/nextcloud/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/stable/nextcloud/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/stable/nextcloud/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/stable/nextcloud/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/stable/nextcloud/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/stable/nextcloud/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/stable/nextcloud/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/stable/nextcloud/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/stable/nextcloud/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/stable/nextcloud/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/stable/nextcloud/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/stable/nextcloud/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/stable/nextcloud/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/stable/nextcloud/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/stable/nextcloud/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/stable/nextcloud/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/stable/nextcloud/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/stable/nextcloud/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/stable/nextcloud/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/stable/nextcloud/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/stable/nextcloud/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/stable/nextcloud/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/stable/nextcloud/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/stable/nextcloud/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/stable/nextcloud/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/stable/nextcloud/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/stable/nextcloud/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/stable/nextcloud/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/stable/nextcloud/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/stable/nextcloud/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/stable/nextcloud/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/stable/nextcloud/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/stable/nextcloud/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/stable/nextcloud/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/stable/nextcloud/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/stable/nextcloud/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/stable/nextcloud/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/stable/nextcloud/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/stable/nextcloud/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/stable/nextcloud/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/stable/nextcloud/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/stable/nextcloud/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/stable/nextcloud/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/stable/nextcloud/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/stable/nextcloud/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/stable/nextcloud/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/stable/nextcloud/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/stable/nextcloud/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/stable/nextcloud/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/stable/nextcloud/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/stable/nextcloud/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/stable/nextcloud/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/stable/nextcloud/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/stable/nextcloud/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/stable/nextcloud/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/stable/nextcloud/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/stable/nextcloud/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/stable/nextcloud/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/stable/nextcloud/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/stable/nextcloud/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/stable/nextcloud/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/stable/nextcloud/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/stable/nextcloud/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/stable/nextcloud/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/stable/nextcloud/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/stable/nextcloud/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/stable/nextcloud/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/stable/nextcloud/templates/library/base_v2_1_8/validations.py b/ix-dev/stable/nextcloud/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/stable/nextcloud/templates/library/base_v2_1_8/validations.py rename to ix-dev/stable/nextcloud/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/stable/nextcloud/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/stable/nextcloud/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/stable/nextcloud/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/stable/nextcloud/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/stable/nextcloud/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/stable/nextcloud/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/stable/nextcloud/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/stable/nextcloud/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/stable/nextcloud/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/stable/nextcloud/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/stable/nextcloud/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/stable/nextcloud/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/stable/nextcloud/templates/library/base_v2_1_8/volume_types.py b/ix-dev/stable/nextcloud/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/stable/nextcloud/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/stable/nextcloud/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/stable/nextcloud/templates/library/base_v2_1_8/volumes.py b/ix-dev/stable/nextcloud/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/stable/nextcloud/templates/library/base_v2_1_8/volumes.py rename to ix-dev/stable/nextcloud/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/stable/photoprism/app.yaml b/ix-dev/stable/photoprism/app.yaml index 30a91d921e..207ef9f8aa 100644 --- a/ix-dev/stable/photoprism/app.yaml +++ b/ix-dev/stable/photoprism/app.yaml @@ -22,8 +22,8 @@ keywords: - media - photos - image -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -42,4 +42,4 @@ sources: - https://photoprism.app/ title: Photoprism train: stable -version: 1.2.7 +version: 1.2.8 diff --git a/ix-dev/stable/photoprism/templates/library/base_v2_1_8/container.py b/ix-dev/stable/photoprism/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/stable/photoprism/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/stable/photoprism/templates/library/base_v2_1_8/ports.py b/ix-dev/stable/photoprism/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/stable/photoprism/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/stable/photoprism/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/stable/photoprism/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/stable/photoprism/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/stable/photoprism/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/stable/photoprism/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/stable/photoprism/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/stable/photoprism/templates/library/base_v2_1_8/__init__.py b/ix-dev/stable/photoprism/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/stable/photoprism/templates/library/base_v2_1_8/__init__.py rename to ix-dev/stable/photoprism/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/stable/photoprism/templates/library/base_v2_1_8/configs.py b/ix-dev/stable/photoprism/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/stable/photoprism/templates/library/base_v2_1_8/configs.py rename to ix-dev/stable/photoprism/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/stable/photoprism/templates/library/base_v2_1_9/container.py b/ix-dev/stable/photoprism/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/stable/photoprism/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/stable/photoprism/templates/library/base_v2_1_8/depends.py b/ix-dev/stable/photoprism/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/stable/photoprism/templates/library/base_v2_1_8/depends.py rename to ix-dev/stable/photoprism/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/stable/photoprism/templates/library/base_v2_1_8/deploy.py b/ix-dev/stable/photoprism/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/stable/photoprism/templates/library/base_v2_1_8/deploy.py rename to ix-dev/stable/photoprism/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/stable/photoprism/templates/library/base_v2_1_8/deps.py b/ix-dev/stable/photoprism/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/stable/photoprism/templates/library/base_v2_1_8/deps.py rename to ix-dev/stable/photoprism/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/stable/photoprism/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/stable/photoprism/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/stable/photoprism/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/stable/photoprism/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/stable/photoprism/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/stable/photoprism/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/stable/photoprism/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/stable/photoprism/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/stable/photoprism/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/stable/photoprism/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/stable/photoprism/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/stable/photoprism/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/stable/photoprism/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/stable/photoprism/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/stable/photoprism/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/stable/photoprism/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/stable/photoprism/templates/library/base_v2_1_8/device.py b/ix-dev/stable/photoprism/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/stable/photoprism/templates/library/base_v2_1_8/device.py rename to ix-dev/stable/photoprism/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/stable/photoprism/templates/library/base_v2_1_8/devices.py b/ix-dev/stable/photoprism/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/stable/photoprism/templates/library/base_v2_1_8/devices.py rename to ix-dev/stable/photoprism/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/stable/photoprism/templates/library/base_v2_1_8/dns.py b/ix-dev/stable/photoprism/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/stable/photoprism/templates/library/base_v2_1_8/dns.py rename to ix-dev/stable/photoprism/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/stable/photoprism/templates/library/base_v2_1_8/environment.py b/ix-dev/stable/photoprism/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/stable/photoprism/templates/library/base_v2_1_8/environment.py rename to ix-dev/stable/photoprism/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/stable/photoprism/templates/library/base_v2_1_8/error.py b/ix-dev/stable/photoprism/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/stable/photoprism/templates/library/base_v2_1_8/error.py rename to ix-dev/stable/photoprism/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/stable/photoprism/templates/library/base_v2_1_8/expose.py b/ix-dev/stable/photoprism/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/stable/photoprism/templates/library/base_v2_1_8/expose.py rename to ix-dev/stable/photoprism/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/stable/photoprism/templates/library/base_v2_1_8/formatter.py b/ix-dev/stable/photoprism/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/stable/photoprism/templates/library/base_v2_1_8/formatter.py rename to ix-dev/stable/photoprism/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/stable/photoprism/templates/library/base_v2_1_8/functions.py b/ix-dev/stable/photoprism/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/stable/photoprism/templates/library/base_v2_1_8/functions.py rename to ix-dev/stable/photoprism/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/stable/photoprism/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/stable/photoprism/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/stable/photoprism/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/stable/photoprism/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/stable/photoprism/templates/library/base_v2_1_8/labels.py b/ix-dev/stable/photoprism/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/stable/photoprism/templates/library/base_v2_1_8/labels.py rename to ix-dev/stable/photoprism/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/stable/photoprism/templates/library/base_v2_1_8/notes.py b/ix-dev/stable/photoprism/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/stable/photoprism/templates/library/base_v2_1_8/notes.py rename to ix-dev/stable/photoprism/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/stable/photoprism/templates/library/base_v2_1_8/portal.py b/ix-dev/stable/photoprism/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/stable/photoprism/templates/library/base_v2_1_8/portal.py rename to ix-dev/stable/photoprism/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/stable/photoprism/templates/library/base_v2_1_8/portals.py b/ix-dev/stable/photoprism/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/stable/photoprism/templates/library/base_v2_1_8/portals.py rename to ix-dev/stable/photoprism/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/stable/photoprism/templates/library/base_v2_1_9/ports.py b/ix-dev/stable/photoprism/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/stable/photoprism/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/stable/photoprism/templates/library/base_v2_1_8/render.py b/ix-dev/stable/photoprism/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/stable/photoprism/templates/library/base_v2_1_8/render.py rename to ix-dev/stable/photoprism/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/stable/photoprism/templates/library/base_v2_1_8/resources.py b/ix-dev/stable/photoprism/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/stable/photoprism/templates/library/base_v2_1_8/resources.py rename to ix-dev/stable/photoprism/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/stable/photoprism/templates/library/base_v2_1_8/restart.py b/ix-dev/stable/photoprism/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/stable/photoprism/templates/library/base_v2_1_8/restart.py rename to ix-dev/stable/photoprism/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/stable/photoprism/templates/library/base_v2_1_8/storage.py b/ix-dev/stable/photoprism/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/stable/photoprism/templates/library/base_v2_1_8/storage.py rename to ix-dev/stable/photoprism/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/stable/photoprism/templates/library/base_v2_1_8/sysctls.py b/ix-dev/stable/photoprism/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/stable/photoprism/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/stable/photoprism/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/stable/photoprism/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/stable/photoprism/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/stable/photoprism/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/stable/photoprism/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/stable/photoprism/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/stable/photoprism/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/stable/photoprism/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/stable/photoprism/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/stable/photoprism/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/stable/photoprism/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/stable/photoprism/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/stable/photoprism/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/stable/photoprism/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/stable/photoprism/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/stable/photoprism/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/stable/photoprism/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/stable/photoprism/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/stable/photoprism/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/stable/photoprism/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/stable/photoprism/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/stable/photoprism/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/stable/photoprism/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/stable/photoprism/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/stable/photoprism/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/stable/photoprism/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/stable/photoprism/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/stable/photoprism/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/stable/photoprism/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/stable/photoprism/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/stable/photoprism/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/stable/photoprism/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/stable/photoprism/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/stable/photoprism/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/stable/photoprism/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/stable/photoprism/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/stable/photoprism/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/stable/photoprism/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/stable/photoprism/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/stable/photoprism/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/stable/photoprism/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/stable/photoprism/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/stable/photoprism/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/stable/photoprism/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/stable/photoprism/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/stable/photoprism/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/stable/photoprism/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/stable/photoprism/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/stable/photoprism/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/stable/photoprism/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/stable/photoprism/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/stable/photoprism/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/stable/photoprism/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/stable/photoprism/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/stable/photoprism/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/stable/photoprism/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/stable/photoprism/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/stable/photoprism/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/stable/photoprism/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/stable/photoprism/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/stable/photoprism/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/stable/photoprism/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/stable/photoprism/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/stable/photoprism/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/stable/photoprism/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/stable/photoprism/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/stable/photoprism/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/stable/photoprism/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/stable/photoprism/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/stable/photoprism/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/stable/photoprism/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/stable/photoprism/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/stable/photoprism/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/stable/photoprism/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/stable/photoprism/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/stable/photoprism/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/stable/photoprism/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/stable/photoprism/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/stable/photoprism/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/stable/photoprism/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/stable/photoprism/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/stable/photoprism/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/stable/photoprism/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/stable/photoprism/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/stable/photoprism/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/stable/photoprism/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/stable/photoprism/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/stable/photoprism/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/stable/photoprism/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/stable/photoprism/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/stable/photoprism/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/stable/photoprism/templates/library/base_v2_1_8/validations.py b/ix-dev/stable/photoprism/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/stable/photoprism/templates/library/base_v2_1_8/validations.py rename to ix-dev/stable/photoprism/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/stable/photoprism/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/stable/photoprism/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/stable/photoprism/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/stable/photoprism/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/stable/photoprism/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/stable/photoprism/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/stable/photoprism/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/stable/photoprism/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/stable/photoprism/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/stable/photoprism/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/stable/photoprism/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/stable/photoprism/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/stable/photoprism/templates/library/base_v2_1_8/volume_types.py b/ix-dev/stable/photoprism/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/stable/photoprism/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/stable/photoprism/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/stable/photoprism/templates/library/base_v2_1_8/volumes.py b/ix-dev/stable/photoprism/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/stable/photoprism/templates/library/base_v2_1_8/volumes.py rename to ix-dev/stable/photoprism/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/stable/pihole/app.yaml b/ix-dev/stable/pihole/app.yaml index 4b5d23f0e2..99330fd1fd 100644 --- a/ix-dev/stable/pihole/app.yaml +++ b/ix-dev/stable/pihole/app.yaml @@ -33,8 +33,8 @@ icon: https://media.sys.truenas.net/apps/pihole/icons/icon.png keywords: - networking - dns -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -53,4 +53,4 @@ sources: - https://github.com/truenas/charts/tree/master/charts/pihole title: Pi-hole train: stable -version: 1.2.5 +version: 1.2.6 diff --git a/ix-dev/stable/pihole/templates/library/base_v2_1_8/container.py b/ix-dev/stable/pihole/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/stable/pihole/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/stable/pihole/templates/library/base_v2_1_8/ports.py b/ix-dev/stable/pihole/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/stable/pihole/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/stable/pihole/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/stable/pihole/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/stable/pihole/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/stable/pihole/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/stable/pihole/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/stable/pihole/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/stable/pihole/templates/library/base_v2_1_8/__init__.py b/ix-dev/stable/pihole/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/stable/pihole/templates/library/base_v2_1_8/__init__.py rename to ix-dev/stable/pihole/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/stable/pihole/templates/library/base_v2_1_8/configs.py b/ix-dev/stable/pihole/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/stable/pihole/templates/library/base_v2_1_8/configs.py rename to ix-dev/stable/pihole/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/stable/pihole/templates/library/base_v2_1_9/container.py b/ix-dev/stable/pihole/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/stable/pihole/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/stable/pihole/templates/library/base_v2_1_8/depends.py b/ix-dev/stable/pihole/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/stable/pihole/templates/library/base_v2_1_8/depends.py rename to ix-dev/stable/pihole/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/stable/pihole/templates/library/base_v2_1_8/deploy.py b/ix-dev/stable/pihole/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/stable/pihole/templates/library/base_v2_1_8/deploy.py rename to ix-dev/stable/pihole/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/stable/pihole/templates/library/base_v2_1_8/deps.py b/ix-dev/stable/pihole/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/stable/pihole/templates/library/base_v2_1_8/deps.py rename to ix-dev/stable/pihole/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/stable/pihole/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/stable/pihole/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/stable/pihole/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/stable/pihole/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/stable/pihole/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/stable/pihole/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/stable/pihole/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/stable/pihole/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/stable/pihole/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/stable/pihole/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/stable/pihole/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/stable/pihole/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/stable/pihole/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/stable/pihole/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/stable/pihole/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/stable/pihole/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/stable/pihole/templates/library/base_v2_1_8/device.py b/ix-dev/stable/pihole/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/stable/pihole/templates/library/base_v2_1_8/device.py rename to ix-dev/stable/pihole/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/stable/pihole/templates/library/base_v2_1_8/devices.py b/ix-dev/stable/pihole/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/stable/pihole/templates/library/base_v2_1_8/devices.py rename to ix-dev/stable/pihole/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/stable/pihole/templates/library/base_v2_1_8/dns.py b/ix-dev/stable/pihole/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/stable/pihole/templates/library/base_v2_1_8/dns.py rename to ix-dev/stable/pihole/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/stable/pihole/templates/library/base_v2_1_8/environment.py b/ix-dev/stable/pihole/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/stable/pihole/templates/library/base_v2_1_8/environment.py rename to ix-dev/stable/pihole/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/stable/pihole/templates/library/base_v2_1_8/error.py b/ix-dev/stable/pihole/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/stable/pihole/templates/library/base_v2_1_8/error.py rename to ix-dev/stable/pihole/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/stable/pihole/templates/library/base_v2_1_8/expose.py b/ix-dev/stable/pihole/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/stable/pihole/templates/library/base_v2_1_8/expose.py rename to ix-dev/stable/pihole/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/stable/pihole/templates/library/base_v2_1_8/formatter.py b/ix-dev/stable/pihole/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/stable/pihole/templates/library/base_v2_1_8/formatter.py rename to ix-dev/stable/pihole/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/stable/pihole/templates/library/base_v2_1_8/functions.py b/ix-dev/stable/pihole/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/stable/pihole/templates/library/base_v2_1_8/functions.py rename to ix-dev/stable/pihole/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/stable/pihole/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/stable/pihole/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/stable/pihole/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/stable/pihole/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/stable/pihole/templates/library/base_v2_1_8/labels.py b/ix-dev/stable/pihole/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/stable/pihole/templates/library/base_v2_1_8/labels.py rename to ix-dev/stable/pihole/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/stable/pihole/templates/library/base_v2_1_8/notes.py b/ix-dev/stable/pihole/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/stable/pihole/templates/library/base_v2_1_8/notes.py rename to ix-dev/stable/pihole/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/stable/pihole/templates/library/base_v2_1_8/portal.py b/ix-dev/stable/pihole/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/stable/pihole/templates/library/base_v2_1_8/portal.py rename to ix-dev/stable/pihole/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/stable/pihole/templates/library/base_v2_1_8/portals.py b/ix-dev/stable/pihole/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/stable/pihole/templates/library/base_v2_1_8/portals.py rename to ix-dev/stable/pihole/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/stable/pihole/templates/library/base_v2_1_9/ports.py b/ix-dev/stable/pihole/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/stable/pihole/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/stable/pihole/templates/library/base_v2_1_8/render.py b/ix-dev/stable/pihole/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/stable/pihole/templates/library/base_v2_1_8/render.py rename to ix-dev/stable/pihole/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/stable/pihole/templates/library/base_v2_1_8/resources.py b/ix-dev/stable/pihole/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/stable/pihole/templates/library/base_v2_1_8/resources.py rename to ix-dev/stable/pihole/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/stable/pihole/templates/library/base_v2_1_8/restart.py b/ix-dev/stable/pihole/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/stable/pihole/templates/library/base_v2_1_8/restart.py rename to ix-dev/stable/pihole/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/stable/pihole/templates/library/base_v2_1_8/storage.py b/ix-dev/stable/pihole/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/stable/pihole/templates/library/base_v2_1_8/storage.py rename to ix-dev/stable/pihole/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/stable/pihole/templates/library/base_v2_1_8/sysctls.py b/ix-dev/stable/pihole/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/stable/pihole/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/stable/pihole/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/stable/pihole/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/stable/pihole/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/stable/pihole/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/stable/pihole/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/stable/pihole/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/stable/pihole/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/stable/pihole/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/stable/pihole/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/stable/pihole/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/stable/pihole/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/stable/pihole/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/stable/pihole/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/stable/pihole/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/stable/pihole/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/stable/pihole/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/stable/pihole/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/stable/pihole/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/stable/pihole/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/stable/pihole/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/stable/pihole/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/stable/pihole/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/stable/pihole/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/stable/pihole/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/stable/pihole/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/stable/pihole/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/stable/pihole/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/stable/pihole/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/stable/pihole/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/stable/pihole/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/stable/pihole/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/stable/pihole/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/stable/pihole/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/stable/pihole/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/stable/pihole/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/stable/pihole/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/stable/pihole/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/stable/pihole/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/stable/pihole/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/stable/pihole/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/stable/pihole/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/stable/pihole/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/stable/pihole/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/stable/pihole/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/stable/pihole/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/stable/pihole/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/stable/pihole/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/stable/pihole/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/stable/pihole/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/stable/pihole/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/stable/pihole/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/stable/pihole/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/stable/pihole/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/stable/pihole/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/stable/pihole/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/stable/pihole/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/stable/pihole/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/stable/pihole/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/stable/pihole/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/stable/pihole/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/stable/pihole/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/stable/pihole/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/stable/pihole/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/stable/pihole/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/stable/pihole/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/stable/pihole/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/stable/pihole/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/stable/pihole/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/stable/pihole/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/stable/pihole/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/stable/pihole/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/stable/pihole/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/stable/pihole/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/stable/pihole/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/stable/pihole/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/stable/pihole/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/stable/pihole/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/stable/pihole/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/stable/pihole/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/stable/pihole/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/stable/pihole/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/stable/pihole/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/stable/pihole/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/stable/pihole/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/stable/pihole/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/stable/pihole/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/stable/pihole/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/stable/pihole/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/stable/pihole/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/stable/pihole/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/stable/pihole/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/stable/pihole/templates/library/base_v2_1_8/validations.py b/ix-dev/stable/pihole/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/stable/pihole/templates/library/base_v2_1_8/validations.py rename to ix-dev/stable/pihole/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/stable/pihole/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/stable/pihole/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/stable/pihole/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/stable/pihole/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/stable/pihole/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/stable/pihole/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/stable/pihole/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/stable/pihole/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/stable/pihole/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/stable/pihole/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/stable/pihole/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/stable/pihole/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/stable/pihole/templates/library/base_v2_1_8/volume_types.py b/ix-dev/stable/pihole/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/stable/pihole/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/stable/pihole/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/stable/pihole/templates/library/base_v2_1_8/volumes.py b/ix-dev/stable/pihole/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/stable/pihole/templates/library/base_v2_1_8/volumes.py rename to ix-dev/stable/pihole/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/stable/plex/app.yaml b/ix-dev/stable/plex/app.yaml index 9fbe3f89bd..81081d3be5 100644 --- a/ix-dev/stable/plex/app.yaml +++ b/ix-dev/stable/plex/app.yaml @@ -27,8 +27,8 @@ keywords: - series - tv - streaming -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -48,4 +48,4 @@ sources: - https://hub.docker.com/r/plexinc/pms-docker title: Plex train: stable -version: 1.1.12 +version: 1.1.13 diff --git a/ix-dev/stable/plex/templates/library/base_v2_1_8/container.py b/ix-dev/stable/plex/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/stable/plex/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/stable/plex/templates/library/base_v2_1_8/ports.py b/ix-dev/stable/plex/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/stable/plex/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/stable/plex/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/stable/plex/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/stable/plex/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/stable/plex/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/stable/plex/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/stable/plex/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/stable/plex/templates/library/base_v2_1_8/__init__.py b/ix-dev/stable/plex/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/stable/plex/templates/library/base_v2_1_8/__init__.py rename to ix-dev/stable/plex/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/stable/plex/templates/library/base_v2_1_8/configs.py b/ix-dev/stable/plex/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/stable/plex/templates/library/base_v2_1_8/configs.py rename to ix-dev/stable/plex/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/stable/plex/templates/library/base_v2_1_9/container.py b/ix-dev/stable/plex/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/stable/plex/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/stable/plex/templates/library/base_v2_1_8/depends.py b/ix-dev/stable/plex/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/stable/plex/templates/library/base_v2_1_8/depends.py rename to ix-dev/stable/plex/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/stable/plex/templates/library/base_v2_1_8/deploy.py b/ix-dev/stable/plex/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/stable/plex/templates/library/base_v2_1_8/deploy.py rename to ix-dev/stable/plex/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/stable/plex/templates/library/base_v2_1_8/deps.py b/ix-dev/stable/plex/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/stable/plex/templates/library/base_v2_1_8/deps.py rename to ix-dev/stable/plex/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/stable/plex/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/stable/plex/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/stable/plex/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/stable/plex/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/stable/plex/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/stable/plex/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/stable/plex/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/stable/plex/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/stable/plex/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/stable/plex/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/stable/plex/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/stable/plex/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/stable/plex/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/stable/plex/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/stable/plex/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/stable/plex/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/stable/plex/templates/library/base_v2_1_8/device.py b/ix-dev/stable/plex/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/stable/plex/templates/library/base_v2_1_8/device.py rename to ix-dev/stable/plex/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/stable/plex/templates/library/base_v2_1_8/devices.py b/ix-dev/stable/plex/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/stable/plex/templates/library/base_v2_1_8/devices.py rename to ix-dev/stable/plex/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/stable/plex/templates/library/base_v2_1_8/dns.py b/ix-dev/stable/plex/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/stable/plex/templates/library/base_v2_1_8/dns.py rename to ix-dev/stable/plex/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/stable/plex/templates/library/base_v2_1_8/environment.py b/ix-dev/stable/plex/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/stable/plex/templates/library/base_v2_1_8/environment.py rename to ix-dev/stable/plex/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/stable/plex/templates/library/base_v2_1_8/error.py b/ix-dev/stable/plex/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/stable/plex/templates/library/base_v2_1_8/error.py rename to ix-dev/stable/plex/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/stable/plex/templates/library/base_v2_1_8/expose.py b/ix-dev/stable/plex/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/stable/plex/templates/library/base_v2_1_8/expose.py rename to ix-dev/stable/plex/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/stable/plex/templates/library/base_v2_1_8/formatter.py b/ix-dev/stable/plex/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/stable/plex/templates/library/base_v2_1_8/formatter.py rename to ix-dev/stable/plex/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/stable/plex/templates/library/base_v2_1_8/functions.py b/ix-dev/stable/plex/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/stable/plex/templates/library/base_v2_1_8/functions.py rename to ix-dev/stable/plex/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/stable/plex/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/stable/plex/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/stable/plex/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/stable/plex/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/stable/plex/templates/library/base_v2_1_8/labels.py b/ix-dev/stable/plex/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/stable/plex/templates/library/base_v2_1_8/labels.py rename to ix-dev/stable/plex/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/stable/plex/templates/library/base_v2_1_8/notes.py b/ix-dev/stable/plex/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/stable/plex/templates/library/base_v2_1_8/notes.py rename to ix-dev/stable/plex/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/stable/plex/templates/library/base_v2_1_8/portal.py b/ix-dev/stable/plex/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/stable/plex/templates/library/base_v2_1_8/portal.py rename to ix-dev/stable/plex/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/stable/plex/templates/library/base_v2_1_8/portals.py b/ix-dev/stable/plex/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/stable/plex/templates/library/base_v2_1_8/portals.py rename to ix-dev/stable/plex/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/stable/plex/templates/library/base_v2_1_9/ports.py b/ix-dev/stable/plex/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/stable/plex/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/stable/plex/templates/library/base_v2_1_8/render.py b/ix-dev/stable/plex/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/stable/plex/templates/library/base_v2_1_8/render.py rename to ix-dev/stable/plex/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/stable/plex/templates/library/base_v2_1_8/resources.py b/ix-dev/stable/plex/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/stable/plex/templates/library/base_v2_1_8/resources.py rename to ix-dev/stable/plex/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/stable/plex/templates/library/base_v2_1_8/restart.py b/ix-dev/stable/plex/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/stable/plex/templates/library/base_v2_1_8/restart.py rename to ix-dev/stable/plex/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/stable/plex/templates/library/base_v2_1_8/storage.py b/ix-dev/stable/plex/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/stable/plex/templates/library/base_v2_1_8/storage.py rename to ix-dev/stable/plex/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/stable/plex/templates/library/base_v2_1_8/sysctls.py b/ix-dev/stable/plex/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/stable/plex/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/stable/plex/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/stable/plex/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/stable/plex/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/stable/plex/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/stable/plex/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/stable/plex/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/stable/plex/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/stable/plex/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/stable/plex/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/stable/plex/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/stable/plex/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/stable/plex/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/stable/plex/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/stable/plex/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/stable/plex/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/stable/plex/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/stable/plex/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/stable/plex/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/stable/plex/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/stable/plex/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/stable/plex/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/stable/plex/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/stable/plex/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/stable/plex/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/stable/plex/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/stable/plex/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/stable/plex/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/stable/plex/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/stable/plex/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/stable/plex/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/stable/plex/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/stable/plex/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/stable/plex/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/stable/plex/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/stable/plex/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/stable/plex/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/stable/plex/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/stable/plex/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/stable/plex/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/stable/plex/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/stable/plex/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/stable/plex/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/stable/plex/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/stable/plex/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/stable/plex/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/stable/plex/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/stable/plex/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/stable/plex/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/stable/plex/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/stable/plex/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/stable/plex/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/stable/plex/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/stable/plex/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/stable/plex/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/stable/plex/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/stable/plex/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/stable/plex/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/stable/plex/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/stable/plex/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/stable/plex/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/stable/plex/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/stable/plex/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/stable/plex/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/stable/plex/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/stable/plex/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/stable/plex/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/stable/plex/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/stable/plex/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/stable/plex/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/stable/plex/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/stable/plex/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/stable/plex/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/stable/plex/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/stable/plex/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/stable/plex/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/stable/plex/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/stable/plex/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/stable/plex/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/stable/plex/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/stable/plex/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/stable/plex/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/stable/plex/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/stable/plex/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/stable/plex/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/stable/plex/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/stable/plex/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/stable/plex/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/stable/plex/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/stable/plex/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/stable/plex/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/stable/plex/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/stable/plex/templates/library/base_v2_1_8/validations.py b/ix-dev/stable/plex/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/stable/plex/templates/library/base_v2_1_8/validations.py rename to ix-dev/stable/plex/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/stable/plex/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/stable/plex/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/stable/plex/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/stable/plex/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/stable/plex/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/stable/plex/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/stable/plex/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/stable/plex/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/stable/plex/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/stable/plex/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/stable/plex/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/stable/plex/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/stable/plex/templates/library/base_v2_1_8/volume_types.py b/ix-dev/stable/plex/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/stable/plex/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/stable/plex/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/stable/plex/templates/library/base_v2_1_8/volumes.py b/ix-dev/stable/plex/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/stable/plex/templates/library/base_v2_1_8/volumes.py rename to ix-dev/stable/plex/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/stable/prometheus/app.yaml b/ix-dev/stable/prometheus/app.yaml index f5fdf8ac86..637ce1d58d 100644 --- a/ix-dev/stable/prometheus/app.yaml +++ b/ix-dev/stable/prometheus/app.yaml @@ -9,8 +9,8 @@ icon: https://media.sys.truenas.net/apps/prometheus/icons/icon.png keywords: - metrics - prometheus -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -30,4 +30,4 @@ sources: - https://prometheus.io title: Prometheus train: stable -version: 1.2.6 +version: 1.2.7 diff --git a/ix-dev/stable/prometheus/templates/library/base_v2_1_8/container.py b/ix-dev/stable/prometheus/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/stable/prometheus/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/stable/prometheus/templates/library/base_v2_1_8/ports.py b/ix-dev/stable/prometheus/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/stable/prometheus/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/stable/prometheus/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/stable/prometheus/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/stable/prometheus/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/stable/prometheus/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/stable/prometheus/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/stable/prometheus/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/stable/prometheus/templates/library/base_v2_1_8/__init__.py b/ix-dev/stable/prometheus/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/stable/prometheus/templates/library/base_v2_1_8/__init__.py rename to ix-dev/stable/prometheus/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/stable/prometheus/templates/library/base_v2_1_8/configs.py b/ix-dev/stable/prometheus/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/stable/prometheus/templates/library/base_v2_1_8/configs.py rename to ix-dev/stable/prometheus/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/stable/prometheus/templates/library/base_v2_1_9/container.py b/ix-dev/stable/prometheus/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/stable/prometheus/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/stable/prometheus/templates/library/base_v2_1_8/depends.py b/ix-dev/stable/prometheus/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/stable/prometheus/templates/library/base_v2_1_8/depends.py rename to ix-dev/stable/prometheus/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/stable/prometheus/templates/library/base_v2_1_8/deploy.py b/ix-dev/stable/prometheus/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/stable/prometheus/templates/library/base_v2_1_8/deploy.py rename to ix-dev/stable/prometheus/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/stable/prometheus/templates/library/base_v2_1_8/deps.py b/ix-dev/stable/prometheus/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/stable/prometheus/templates/library/base_v2_1_8/deps.py rename to ix-dev/stable/prometheus/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/stable/prometheus/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/stable/prometheus/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/stable/prometheus/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/stable/prometheus/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/stable/prometheus/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/stable/prometheus/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/stable/prometheus/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/stable/prometheus/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/stable/prometheus/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/stable/prometheus/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/stable/prometheus/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/stable/prometheus/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/stable/prometheus/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/stable/prometheus/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/stable/prometheus/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/stable/prometheus/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/stable/prometheus/templates/library/base_v2_1_8/device.py b/ix-dev/stable/prometheus/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/stable/prometheus/templates/library/base_v2_1_8/device.py rename to ix-dev/stable/prometheus/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/stable/prometheus/templates/library/base_v2_1_8/devices.py b/ix-dev/stable/prometheus/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/stable/prometheus/templates/library/base_v2_1_8/devices.py rename to ix-dev/stable/prometheus/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/stable/prometheus/templates/library/base_v2_1_8/dns.py b/ix-dev/stable/prometheus/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/stable/prometheus/templates/library/base_v2_1_8/dns.py rename to ix-dev/stable/prometheus/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/stable/prometheus/templates/library/base_v2_1_8/environment.py b/ix-dev/stable/prometheus/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/stable/prometheus/templates/library/base_v2_1_8/environment.py rename to ix-dev/stable/prometheus/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/stable/prometheus/templates/library/base_v2_1_8/error.py b/ix-dev/stable/prometheus/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/stable/prometheus/templates/library/base_v2_1_8/error.py rename to ix-dev/stable/prometheus/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/stable/prometheus/templates/library/base_v2_1_8/expose.py b/ix-dev/stable/prometheus/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/stable/prometheus/templates/library/base_v2_1_8/expose.py rename to ix-dev/stable/prometheus/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/stable/prometheus/templates/library/base_v2_1_8/formatter.py b/ix-dev/stable/prometheus/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/stable/prometheus/templates/library/base_v2_1_8/formatter.py rename to ix-dev/stable/prometheus/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/stable/prometheus/templates/library/base_v2_1_8/functions.py b/ix-dev/stable/prometheus/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/stable/prometheus/templates/library/base_v2_1_8/functions.py rename to ix-dev/stable/prometheus/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/stable/prometheus/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/stable/prometheus/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/stable/prometheus/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/stable/prometheus/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/stable/prometheus/templates/library/base_v2_1_8/labels.py b/ix-dev/stable/prometheus/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/stable/prometheus/templates/library/base_v2_1_8/labels.py rename to ix-dev/stable/prometheus/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/stable/prometheus/templates/library/base_v2_1_8/notes.py b/ix-dev/stable/prometheus/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/stable/prometheus/templates/library/base_v2_1_8/notes.py rename to ix-dev/stable/prometheus/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/stable/prometheus/templates/library/base_v2_1_8/portal.py b/ix-dev/stable/prometheus/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/stable/prometheus/templates/library/base_v2_1_8/portal.py rename to ix-dev/stable/prometheus/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/stable/prometheus/templates/library/base_v2_1_8/portals.py b/ix-dev/stable/prometheus/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/stable/prometheus/templates/library/base_v2_1_8/portals.py rename to ix-dev/stable/prometheus/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/stable/prometheus/templates/library/base_v2_1_9/ports.py b/ix-dev/stable/prometheus/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/stable/prometheus/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/stable/prometheus/templates/library/base_v2_1_8/render.py b/ix-dev/stable/prometheus/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/stable/prometheus/templates/library/base_v2_1_8/render.py rename to ix-dev/stable/prometheus/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/stable/prometheus/templates/library/base_v2_1_8/resources.py b/ix-dev/stable/prometheus/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/stable/prometheus/templates/library/base_v2_1_8/resources.py rename to ix-dev/stable/prometheus/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/stable/prometheus/templates/library/base_v2_1_8/restart.py b/ix-dev/stable/prometheus/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/stable/prometheus/templates/library/base_v2_1_8/restart.py rename to ix-dev/stable/prometheus/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/stable/prometheus/templates/library/base_v2_1_8/storage.py b/ix-dev/stable/prometheus/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/stable/prometheus/templates/library/base_v2_1_8/storage.py rename to ix-dev/stable/prometheus/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/stable/prometheus/templates/library/base_v2_1_8/sysctls.py b/ix-dev/stable/prometheus/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/stable/prometheus/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/stable/prometheus/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/stable/prometheus/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/stable/prometheus/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/stable/prometheus/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/stable/prometheus/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/stable/prometheus/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/stable/prometheus/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/stable/prometheus/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/stable/prometheus/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/stable/prometheus/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/stable/prometheus/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/stable/prometheus/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/stable/prometheus/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/stable/prometheus/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/stable/prometheus/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/stable/prometheus/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/stable/prometheus/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/stable/prometheus/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/stable/prometheus/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/stable/prometheus/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/stable/prometheus/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/stable/prometheus/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/stable/prometheus/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/stable/prometheus/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/stable/prometheus/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/stable/prometheus/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/stable/prometheus/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/stable/prometheus/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/stable/prometheus/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/stable/prometheus/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/stable/prometheus/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/stable/prometheus/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/stable/prometheus/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/stable/prometheus/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/stable/prometheus/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/stable/prometheus/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/stable/prometheus/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/stable/prometheus/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/stable/prometheus/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/stable/prometheus/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/stable/prometheus/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/stable/prometheus/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/stable/prometheus/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/stable/prometheus/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/stable/prometheus/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/stable/prometheus/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/stable/prometheus/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/stable/prometheus/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/stable/prometheus/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/stable/prometheus/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/stable/prometheus/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/stable/prometheus/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/stable/prometheus/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/stable/prometheus/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/stable/prometheus/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/stable/prometheus/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/stable/prometheus/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/stable/prometheus/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/stable/prometheus/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/stable/prometheus/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/stable/prometheus/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/stable/prometheus/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/stable/prometheus/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/stable/prometheus/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/stable/prometheus/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/stable/prometheus/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/stable/prometheus/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/stable/prometheus/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/stable/prometheus/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/stable/prometheus/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/stable/prometheus/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/stable/prometheus/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/stable/prometheus/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/stable/prometheus/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/stable/prometheus/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/stable/prometheus/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/stable/prometheus/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/stable/prometheus/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/stable/prometheus/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/stable/prometheus/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/stable/prometheus/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/stable/prometheus/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/stable/prometheus/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/stable/prometheus/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/stable/prometheus/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/stable/prometheus/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/stable/prometheus/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/stable/prometheus/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/stable/prometheus/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/stable/prometheus/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/stable/prometheus/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/stable/prometheus/templates/library/base_v2_1_8/validations.py b/ix-dev/stable/prometheus/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/stable/prometheus/templates/library/base_v2_1_8/validations.py rename to ix-dev/stable/prometheus/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/stable/prometheus/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/stable/prometheus/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/stable/prometheus/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/stable/prometheus/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/stable/prometheus/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/stable/prometheus/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/stable/prometheus/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/stable/prometheus/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/stable/prometheus/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/stable/prometheus/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/stable/prometheus/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/stable/prometheus/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/stable/prometheus/templates/library/base_v2_1_8/volume_types.py b/ix-dev/stable/prometheus/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/stable/prometheus/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/stable/prometheus/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/stable/prometheus/templates/library/base_v2_1_8/volumes.py b/ix-dev/stable/prometheus/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/stable/prometheus/templates/library/base_v2_1_8/volumes.py rename to ix-dev/stable/prometheus/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/stable/storj/app.yaml b/ix-dev/stable/storj/app.yaml index b78e0f68a9..61ac528898 100644 --- a/ix-dev/stable/storj/app.yaml +++ b/ix-dev/stable/storj/app.yaml @@ -18,8 +18,8 @@ keywords: - networking - financial - file-sharing -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -37,4 +37,4 @@ sources: - https://www.storj.io title: Storj train: stable -version: 1.2.5 +version: 1.2.6 diff --git a/ix-dev/stable/storj/templates/library/base_v2_1_8/container.py b/ix-dev/stable/storj/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/stable/storj/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/stable/storj/templates/library/base_v2_1_8/ports.py b/ix-dev/stable/storj/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/stable/storj/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/stable/storj/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/stable/storj/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/stable/storj/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/stable/storj/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/stable/storj/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/stable/storj/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/stable/storj/templates/library/base_v2_1_8/__init__.py b/ix-dev/stable/storj/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/stable/storj/templates/library/base_v2_1_8/__init__.py rename to ix-dev/stable/storj/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/stable/storj/templates/library/base_v2_1_8/configs.py b/ix-dev/stable/storj/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/stable/storj/templates/library/base_v2_1_8/configs.py rename to ix-dev/stable/storj/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/stable/storj/templates/library/base_v2_1_9/container.py b/ix-dev/stable/storj/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/stable/storj/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/stable/storj/templates/library/base_v2_1_8/depends.py b/ix-dev/stable/storj/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/stable/storj/templates/library/base_v2_1_8/depends.py rename to ix-dev/stable/storj/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/stable/storj/templates/library/base_v2_1_8/deploy.py b/ix-dev/stable/storj/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/stable/storj/templates/library/base_v2_1_8/deploy.py rename to ix-dev/stable/storj/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/stable/storj/templates/library/base_v2_1_8/deps.py b/ix-dev/stable/storj/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/stable/storj/templates/library/base_v2_1_8/deps.py rename to ix-dev/stable/storj/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/stable/storj/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/stable/storj/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/stable/storj/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/stable/storj/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/stable/storj/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/stable/storj/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/stable/storj/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/stable/storj/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/stable/storj/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/stable/storj/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/stable/storj/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/stable/storj/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/stable/storj/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/stable/storj/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/stable/storj/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/stable/storj/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/stable/storj/templates/library/base_v2_1_8/device.py b/ix-dev/stable/storj/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/stable/storj/templates/library/base_v2_1_8/device.py rename to ix-dev/stable/storj/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/stable/storj/templates/library/base_v2_1_8/devices.py b/ix-dev/stable/storj/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/stable/storj/templates/library/base_v2_1_8/devices.py rename to ix-dev/stable/storj/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/stable/storj/templates/library/base_v2_1_8/dns.py b/ix-dev/stable/storj/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/stable/storj/templates/library/base_v2_1_8/dns.py rename to ix-dev/stable/storj/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/stable/storj/templates/library/base_v2_1_8/environment.py b/ix-dev/stable/storj/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/stable/storj/templates/library/base_v2_1_8/environment.py rename to ix-dev/stable/storj/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/stable/storj/templates/library/base_v2_1_8/error.py b/ix-dev/stable/storj/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/stable/storj/templates/library/base_v2_1_8/error.py rename to ix-dev/stable/storj/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/stable/storj/templates/library/base_v2_1_8/expose.py b/ix-dev/stable/storj/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/stable/storj/templates/library/base_v2_1_8/expose.py rename to ix-dev/stable/storj/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/stable/storj/templates/library/base_v2_1_8/formatter.py b/ix-dev/stable/storj/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/stable/storj/templates/library/base_v2_1_8/formatter.py rename to ix-dev/stable/storj/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/stable/storj/templates/library/base_v2_1_8/functions.py b/ix-dev/stable/storj/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/stable/storj/templates/library/base_v2_1_8/functions.py rename to ix-dev/stable/storj/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/stable/storj/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/stable/storj/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/stable/storj/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/stable/storj/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/stable/storj/templates/library/base_v2_1_8/labels.py b/ix-dev/stable/storj/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/stable/storj/templates/library/base_v2_1_8/labels.py rename to ix-dev/stable/storj/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/stable/storj/templates/library/base_v2_1_8/notes.py b/ix-dev/stable/storj/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/stable/storj/templates/library/base_v2_1_8/notes.py rename to ix-dev/stable/storj/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/stable/storj/templates/library/base_v2_1_8/portal.py b/ix-dev/stable/storj/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/stable/storj/templates/library/base_v2_1_8/portal.py rename to ix-dev/stable/storj/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/stable/storj/templates/library/base_v2_1_8/portals.py b/ix-dev/stable/storj/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/stable/storj/templates/library/base_v2_1_8/portals.py rename to ix-dev/stable/storj/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/stable/storj/templates/library/base_v2_1_9/ports.py b/ix-dev/stable/storj/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/stable/storj/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/stable/storj/templates/library/base_v2_1_8/render.py b/ix-dev/stable/storj/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/stable/storj/templates/library/base_v2_1_8/render.py rename to ix-dev/stable/storj/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/stable/storj/templates/library/base_v2_1_8/resources.py b/ix-dev/stable/storj/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/stable/storj/templates/library/base_v2_1_8/resources.py rename to ix-dev/stable/storj/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/stable/storj/templates/library/base_v2_1_8/restart.py b/ix-dev/stable/storj/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/stable/storj/templates/library/base_v2_1_8/restart.py rename to ix-dev/stable/storj/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/stable/storj/templates/library/base_v2_1_8/storage.py b/ix-dev/stable/storj/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/stable/storj/templates/library/base_v2_1_8/storage.py rename to ix-dev/stable/storj/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/stable/storj/templates/library/base_v2_1_8/sysctls.py b/ix-dev/stable/storj/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/stable/storj/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/stable/storj/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/stable/storj/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/stable/storj/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/stable/storj/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/stable/storj/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/stable/storj/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/stable/storj/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/stable/storj/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/stable/storj/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/stable/storj/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/stable/storj/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/stable/storj/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/stable/storj/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/stable/storj/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/stable/storj/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/stable/storj/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/stable/storj/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/stable/storj/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/stable/storj/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/stable/storj/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/stable/storj/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/stable/storj/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/stable/storj/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/stable/storj/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/stable/storj/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/stable/storj/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/stable/storj/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/stable/storj/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/stable/storj/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/stable/storj/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/stable/storj/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/stable/storj/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/stable/storj/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/stable/storj/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/stable/storj/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/stable/storj/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/stable/storj/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/stable/storj/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/stable/storj/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/stable/storj/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/stable/storj/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/stable/storj/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/stable/storj/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/stable/storj/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/stable/storj/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/stable/storj/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/stable/storj/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/stable/storj/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/stable/storj/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/stable/storj/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/stable/storj/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/stable/storj/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/stable/storj/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/stable/storj/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/stable/storj/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/stable/storj/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/stable/storj/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/stable/storj/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/stable/storj/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/stable/storj/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/stable/storj/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/stable/storj/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/stable/storj/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/stable/storj/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/stable/storj/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/stable/storj/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/stable/storj/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/stable/storj/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/stable/storj/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/stable/storj/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/stable/storj/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/stable/storj/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/stable/storj/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/stable/storj/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/stable/storj/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/stable/storj/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/stable/storj/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/stable/storj/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/stable/storj/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/stable/storj/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/stable/storj/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/stable/storj/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/stable/storj/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/stable/storj/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/stable/storj/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/stable/storj/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/stable/storj/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/stable/storj/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/stable/storj/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/stable/storj/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/stable/storj/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/stable/storj/templates/library/base_v2_1_8/validations.py b/ix-dev/stable/storj/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/stable/storj/templates/library/base_v2_1_8/validations.py rename to ix-dev/stable/storj/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/stable/storj/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/stable/storj/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/stable/storj/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/stable/storj/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/stable/storj/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/stable/storj/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/stable/storj/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/stable/storj/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/stable/storj/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/stable/storj/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/stable/storj/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/stable/storj/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/stable/storj/templates/library/base_v2_1_8/volume_types.py b/ix-dev/stable/storj/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/stable/storj/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/stable/storj/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/stable/storj/templates/library/base_v2_1_8/volumes.py b/ix-dev/stable/storj/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/stable/storj/templates/library/base_v2_1_8/volumes.py rename to ix-dev/stable/storj/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/stable/syncthing/app.yaml b/ix-dev/stable/syncthing/app.yaml index 1a4f5a09fb..090f220a9b 100644 --- a/ix-dev/stable/syncthing/app.yaml +++ b/ix-dev/stable/syncthing/app.yaml @@ -26,8 +26,8 @@ keywords: - sync - file-sharing - backup -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -50,4 +50,4 @@ sources: - https://hub.docker.com/r/syncthing/syncthing title: Syncthing train: stable -version: 1.1.9 +version: 1.1.10 diff --git a/ix-dev/stable/syncthing/templates/library/base_v2_1_8/container.py b/ix-dev/stable/syncthing/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/stable/syncthing/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/stable/syncthing/templates/library/base_v2_1_8/ports.py b/ix-dev/stable/syncthing/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/stable/syncthing/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/stable/syncthing/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/stable/syncthing/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/stable/syncthing/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/stable/syncthing/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/stable/syncthing/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/stable/syncthing/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/stable/syncthing/templates/library/base_v2_1_8/__init__.py b/ix-dev/stable/syncthing/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/stable/syncthing/templates/library/base_v2_1_8/__init__.py rename to ix-dev/stable/syncthing/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/stable/syncthing/templates/library/base_v2_1_8/configs.py b/ix-dev/stable/syncthing/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/stable/syncthing/templates/library/base_v2_1_8/configs.py rename to ix-dev/stable/syncthing/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/stable/syncthing/templates/library/base_v2_1_9/container.py b/ix-dev/stable/syncthing/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/stable/syncthing/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/stable/syncthing/templates/library/base_v2_1_8/depends.py b/ix-dev/stable/syncthing/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/stable/syncthing/templates/library/base_v2_1_8/depends.py rename to ix-dev/stable/syncthing/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/stable/syncthing/templates/library/base_v2_1_8/deploy.py b/ix-dev/stable/syncthing/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/stable/syncthing/templates/library/base_v2_1_8/deploy.py rename to ix-dev/stable/syncthing/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/stable/syncthing/templates/library/base_v2_1_8/deps.py b/ix-dev/stable/syncthing/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/stable/syncthing/templates/library/base_v2_1_8/deps.py rename to ix-dev/stable/syncthing/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/stable/syncthing/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/stable/syncthing/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/stable/syncthing/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/stable/syncthing/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/stable/syncthing/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/stable/syncthing/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/stable/syncthing/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/stable/syncthing/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/stable/syncthing/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/stable/syncthing/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/stable/syncthing/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/stable/syncthing/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/stable/syncthing/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/stable/syncthing/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/stable/syncthing/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/stable/syncthing/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/stable/syncthing/templates/library/base_v2_1_8/device.py b/ix-dev/stable/syncthing/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/stable/syncthing/templates/library/base_v2_1_8/device.py rename to ix-dev/stable/syncthing/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/stable/syncthing/templates/library/base_v2_1_8/devices.py b/ix-dev/stable/syncthing/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/stable/syncthing/templates/library/base_v2_1_8/devices.py rename to ix-dev/stable/syncthing/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/stable/syncthing/templates/library/base_v2_1_8/dns.py b/ix-dev/stable/syncthing/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/stable/syncthing/templates/library/base_v2_1_8/dns.py rename to ix-dev/stable/syncthing/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/stable/syncthing/templates/library/base_v2_1_8/environment.py b/ix-dev/stable/syncthing/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/stable/syncthing/templates/library/base_v2_1_8/environment.py rename to ix-dev/stable/syncthing/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/stable/syncthing/templates/library/base_v2_1_8/error.py b/ix-dev/stable/syncthing/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/stable/syncthing/templates/library/base_v2_1_8/error.py rename to ix-dev/stable/syncthing/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/stable/syncthing/templates/library/base_v2_1_8/expose.py b/ix-dev/stable/syncthing/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/stable/syncthing/templates/library/base_v2_1_8/expose.py rename to ix-dev/stable/syncthing/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/stable/syncthing/templates/library/base_v2_1_8/formatter.py b/ix-dev/stable/syncthing/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/stable/syncthing/templates/library/base_v2_1_8/formatter.py rename to ix-dev/stable/syncthing/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/stable/syncthing/templates/library/base_v2_1_8/functions.py b/ix-dev/stable/syncthing/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/stable/syncthing/templates/library/base_v2_1_8/functions.py rename to ix-dev/stable/syncthing/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/stable/syncthing/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/stable/syncthing/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/stable/syncthing/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/stable/syncthing/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/stable/syncthing/templates/library/base_v2_1_8/labels.py b/ix-dev/stable/syncthing/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/stable/syncthing/templates/library/base_v2_1_8/labels.py rename to ix-dev/stable/syncthing/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/stable/syncthing/templates/library/base_v2_1_8/notes.py b/ix-dev/stable/syncthing/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/stable/syncthing/templates/library/base_v2_1_8/notes.py rename to ix-dev/stable/syncthing/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/stable/syncthing/templates/library/base_v2_1_8/portal.py b/ix-dev/stable/syncthing/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/stable/syncthing/templates/library/base_v2_1_8/portal.py rename to ix-dev/stable/syncthing/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/stable/syncthing/templates/library/base_v2_1_8/portals.py b/ix-dev/stable/syncthing/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/stable/syncthing/templates/library/base_v2_1_8/portals.py rename to ix-dev/stable/syncthing/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/stable/syncthing/templates/library/base_v2_1_9/ports.py b/ix-dev/stable/syncthing/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/stable/syncthing/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/stable/syncthing/templates/library/base_v2_1_8/render.py b/ix-dev/stable/syncthing/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/stable/syncthing/templates/library/base_v2_1_8/render.py rename to ix-dev/stable/syncthing/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/stable/syncthing/templates/library/base_v2_1_8/resources.py b/ix-dev/stable/syncthing/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/stable/syncthing/templates/library/base_v2_1_8/resources.py rename to ix-dev/stable/syncthing/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/stable/syncthing/templates/library/base_v2_1_8/restart.py b/ix-dev/stable/syncthing/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/stable/syncthing/templates/library/base_v2_1_8/restart.py rename to ix-dev/stable/syncthing/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/stable/syncthing/templates/library/base_v2_1_8/storage.py b/ix-dev/stable/syncthing/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/stable/syncthing/templates/library/base_v2_1_8/storage.py rename to ix-dev/stable/syncthing/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/stable/syncthing/templates/library/base_v2_1_8/sysctls.py b/ix-dev/stable/syncthing/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/stable/syncthing/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/stable/syncthing/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/stable/syncthing/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/stable/syncthing/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/stable/syncthing/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/stable/syncthing/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/stable/syncthing/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/stable/syncthing/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/stable/syncthing/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/stable/syncthing/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/stable/syncthing/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/stable/syncthing/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/stable/syncthing/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/stable/syncthing/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/stable/syncthing/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/stable/syncthing/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/stable/syncthing/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/stable/syncthing/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/stable/syncthing/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/stable/syncthing/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/stable/syncthing/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/stable/syncthing/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/stable/syncthing/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/stable/syncthing/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/stable/syncthing/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/stable/syncthing/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/stable/syncthing/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/stable/syncthing/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/stable/syncthing/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/stable/syncthing/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/stable/syncthing/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/stable/syncthing/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/stable/syncthing/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/stable/syncthing/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/stable/syncthing/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/stable/syncthing/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/stable/syncthing/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/stable/syncthing/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/stable/syncthing/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/stable/syncthing/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/stable/syncthing/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/stable/syncthing/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/stable/syncthing/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/stable/syncthing/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/stable/syncthing/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/stable/syncthing/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/stable/syncthing/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/stable/syncthing/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/stable/syncthing/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/stable/syncthing/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/stable/syncthing/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/stable/syncthing/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/stable/syncthing/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/stable/syncthing/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/stable/syncthing/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/stable/syncthing/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/stable/syncthing/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/stable/syncthing/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/stable/syncthing/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/stable/syncthing/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/stable/syncthing/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/stable/syncthing/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/stable/syncthing/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/stable/syncthing/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/stable/syncthing/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/stable/syncthing/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/stable/syncthing/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/stable/syncthing/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/stable/syncthing/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/stable/syncthing/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/stable/syncthing/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/stable/syncthing/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/stable/syncthing/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/stable/syncthing/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/stable/syncthing/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/stable/syncthing/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/stable/syncthing/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/stable/syncthing/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/stable/syncthing/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/stable/syncthing/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/stable/syncthing/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/stable/syncthing/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/stable/syncthing/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/stable/syncthing/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/stable/syncthing/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/stable/syncthing/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/stable/syncthing/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/stable/syncthing/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/stable/syncthing/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/stable/syncthing/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/stable/syncthing/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/stable/syncthing/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/stable/syncthing/templates/library/base_v2_1_8/validations.py b/ix-dev/stable/syncthing/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/stable/syncthing/templates/library/base_v2_1_8/validations.py rename to ix-dev/stable/syncthing/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/stable/syncthing/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/stable/syncthing/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/stable/syncthing/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/stable/syncthing/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/stable/syncthing/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/stable/syncthing/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/stable/syncthing/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/stable/syncthing/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/stable/syncthing/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/stable/syncthing/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/stable/syncthing/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/stable/syncthing/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/stable/syncthing/templates/library/base_v2_1_8/volume_types.py b/ix-dev/stable/syncthing/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/stable/syncthing/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/stable/syncthing/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/stable/syncthing/templates/library/base_v2_1_8/volumes.py b/ix-dev/stable/syncthing/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/stable/syncthing/templates/library/base_v2_1_8/volumes.py rename to ix-dev/stable/syncthing/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/stable/wg-easy/app.yaml b/ix-dev/stable/wg-easy/app.yaml index c7ffef6b85..0c1156bce2 100644 --- a/ix-dev/stable/wg-easy/app.yaml +++ b/ix-dev/stable/wg-easy/app.yaml @@ -16,8 +16,8 @@ keywords: - wireguard - network - vpn -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -35,4 +35,4 @@ sources: - https://github.com/wg-easy/wg-easy title: WG Easy train: stable -version: 1.1.9 +version: 1.1.10 diff --git a/ix-dev/stable/wg-easy/templates/library/base_v2_1_8/container.py b/ix-dev/stable/wg-easy/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/stable/wg-easy/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/stable/wg-easy/templates/library/base_v2_1_8/ports.py b/ix-dev/stable/wg-easy/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/stable/wg-easy/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/stable/wg-easy/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/stable/wg-easy/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/stable/wg-easy/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/stable/wg-easy/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/stable/wg-easy/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/stable/wg-easy/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/stable/wg-easy/templates/library/base_v2_1_8/__init__.py b/ix-dev/stable/wg-easy/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/stable/wg-easy/templates/library/base_v2_1_8/__init__.py rename to ix-dev/stable/wg-easy/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/stable/wg-easy/templates/library/base_v2_1_8/configs.py b/ix-dev/stable/wg-easy/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/stable/wg-easy/templates/library/base_v2_1_8/configs.py rename to ix-dev/stable/wg-easy/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/stable/wg-easy/templates/library/base_v2_1_9/container.py b/ix-dev/stable/wg-easy/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/stable/wg-easy/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/stable/wg-easy/templates/library/base_v2_1_8/depends.py b/ix-dev/stable/wg-easy/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/stable/wg-easy/templates/library/base_v2_1_8/depends.py rename to ix-dev/stable/wg-easy/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/stable/wg-easy/templates/library/base_v2_1_8/deploy.py b/ix-dev/stable/wg-easy/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/stable/wg-easy/templates/library/base_v2_1_8/deploy.py rename to ix-dev/stable/wg-easy/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/stable/wg-easy/templates/library/base_v2_1_8/deps.py b/ix-dev/stable/wg-easy/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/stable/wg-easy/templates/library/base_v2_1_8/deps.py rename to ix-dev/stable/wg-easy/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/stable/wg-easy/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/stable/wg-easy/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/stable/wg-easy/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/stable/wg-easy/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/stable/wg-easy/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/stable/wg-easy/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/stable/wg-easy/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/stable/wg-easy/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/stable/wg-easy/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/stable/wg-easy/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/stable/wg-easy/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/stable/wg-easy/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/stable/wg-easy/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/stable/wg-easy/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/stable/wg-easy/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/stable/wg-easy/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/stable/wg-easy/templates/library/base_v2_1_8/device.py b/ix-dev/stable/wg-easy/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/stable/wg-easy/templates/library/base_v2_1_8/device.py rename to ix-dev/stable/wg-easy/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/stable/wg-easy/templates/library/base_v2_1_8/devices.py b/ix-dev/stable/wg-easy/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/stable/wg-easy/templates/library/base_v2_1_8/devices.py rename to ix-dev/stable/wg-easy/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/stable/wg-easy/templates/library/base_v2_1_8/dns.py b/ix-dev/stable/wg-easy/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/stable/wg-easy/templates/library/base_v2_1_8/dns.py rename to ix-dev/stable/wg-easy/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/stable/wg-easy/templates/library/base_v2_1_8/environment.py b/ix-dev/stable/wg-easy/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/stable/wg-easy/templates/library/base_v2_1_8/environment.py rename to ix-dev/stable/wg-easy/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/stable/wg-easy/templates/library/base_v2_1_8/error.py b/ix-dev/stable/wg-easy/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/stable/wg-easy/templates/library/base_v2_1_8/error.py rename to ix-dev/stable/wg-easy/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/stable/wg-easy/templates/library/base_v2_1_8/expose.py b/ix-dev/stable/wg-easy/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/stable/wg-easy/templates/library/base_v2_1_8/expose.py rename to ix-dev/stable/wg-easy/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/stable/wg-easy/templates/library/base_v2_1_8/formatter.py b/ix-dev/stable/wg-easy/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/stable/wg-easy/templates/library/base_v2_1_8/formatter.py rename to ix-dev/stable/wg-easy/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/stable/wg-easy/templates/library/base_v2_1_8/functions.py b/ix-dev/stable/wg-easy/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/stable/wg-easy/templates/library/base_v2_1_8/functions.py rename to ix-dev/stable/wg-easy/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/stable/wg-easy/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/stable/wg-easy/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/stable/wg-easy/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/stable/wg-easy/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/stable/wg-easy/templates/library/base_v2_1_8/labels.py b/ix-dev/stable/wg-easy/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/stable/wg-easy/templates/library/base_v2_1_8/labels.py rename to ix-dev/stable/wg-easy/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/stable/wg-easy/templates/library/base_v2_1_8/notes.py b/ix-dev/stable/wg-easy/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/stable/wg-easy/templates/library/base_v2_1_8/notes.py rename to ix-dev/stable/wg-easy/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/stable/wg-easy/templates/library/base_v2_1_8/portal.py b/ix-dev/stable/wg-easy/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/stable/wg-easy/templates/library/base_v2_1_8/portal.py rename to ix-dev/stable/wg-easy/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/stable/wg-easy/templates/library/base_v2_1_8/portals.py b/ix-dev/stable/wg-easy/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/stable/wg-easy/templates/library/base_v2_1_8/portals.py rename to ix-dev/stable/wg-easy/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/stable/wg-easy/templates/library/base_v2_1_9/ports.py b/ix-dev/stable/wg-easy/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/stable/wg-easy/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/stable/wg-easy/templates/library/base_v2_1_8/render.py b/ix-dev/stable/wg-easy/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/stable/wg-easy/templates/library/base_v2_1_8/render.py rename to ix-dev/stable/wg-easy/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/stable/wg-easy/templates/library/base_v2_1_8/resources.py b/ix-dev/stable/wg-easy/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/stable/wg-easy/templates/library/base_v2_1_8/resources.py rename to ix-dev/stable/wg-easy/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/stable/wg-easy/templates/library/base_v2_1_8/restart.py b/ix-dev/stable/wg-easy/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/stable/wg-easy/templates/library/base_v2_1_8/restart.py rename to ix-dev/stable/wg-easy/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/stable/wg-easy/templates/library/base_v2_1_8/storage.py b/ix-dev/stable/wg-easy/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/stable/wg-easy/templates/library/base_v2_1_8/storage.py rename to ix-dev/stable/wg-easy/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/stable/wg-easy/templates/library/base_v2_1_8/sysctls.py b/ix-dev/stable/wg-easy/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/stable/wg-easy/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/stable/wg-easy/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/stable/wg-easy/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/stable/wg-easy/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/stable/wg-easy/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/stable/wg-easy/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/stable/wg-easy/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/stable/wg-easy/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/stable/wg-easy/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/stable/wg-easy/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/stable/wg-easy/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/stable/wg-easy/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/stable/wg-easy/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/stable/wg-easy/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/stable/wg-easy/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/stable/wg-easy/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/stable/wg-easy/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/stable/wg-easy/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/stable/wg-easy/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/stable/wg-easy/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/stable/wg-easy/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/stable/wg-easy/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/stable/wg-easy/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/stable/wg-easy/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/stable/wg-easy/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/stable/wg-easy/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/stable/wg-easy/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/stable/wg-easy/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/stable/wg-easy/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/stable/wg-easy/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/stable/wg-easy/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/stable/wg-easy/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/stable/wg-easy/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/stable/wg-easy/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/stable/wg-easy/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/stable/wg-easy/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/stable/wg-easy/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/stable/wg-easy/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/stable/wg-easy/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/stable/wg-easy/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/stable/wg-easy/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/stable/wg-easy/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/stable/wg-easy/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/stable/wg-easy/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/stable/wg-easy/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/stable/wg-easy/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/stable/wg-easy/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/stable/wg-easy/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/stable/wg-easy/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/stable/wg-easy/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/stable/wg-easy/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/stable/wg-easy/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/stable/wg-easy/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/stable/wg-easy/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/stable/wg-easy/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/stable/wg-easy/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/stable/wg-easy/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/stable/wg-easy/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/stable/wg-easy/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/stable/wg-easy/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/stable/wg-easy/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/stable/wg-easy/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/stable/wg-easy/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/stable/wg-easy/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/stable/wg-easy/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/stable/wg-easy/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/stable/wg-easy/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/stable/wg-easy/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/stable/wg-easy/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/stable/wg-easy/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/stable/wg-easy/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/stable/wg-easy/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/stable/wg-easy/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/stable/wg-easy/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/stable/wg-easy/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/stable/wg-easy/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/stable/wg-easy/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/stable/wg-easy/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/stable/wg-easy/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/stable/wg-easy/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/stable/wg-easy/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/stable/wg-easy/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/stable/wg-easy/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/stable/wg-easy/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/stable/wg-easy/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/stable/wg-easy/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/stable/wg-easy/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/stable/wg-easy/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/stable/wg-easy/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/stable/wg-easy/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/stable/wg-easy/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/stable/wg-easy/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/stable/wg-easy/templates/library/base_v2_1_8/validations.py b/ix-dev/stable/wg-easy/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/stable/wg-easy/templates/library/base_v2_1_8/validations.py rename to ix-dev/stable/wg-easy/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/stable/wg-easy/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/stable/wg-easy/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/stable/wg-easy/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/stable/wg-easy/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/stable/wg-easy/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/stable/wg-easy/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/stable/wg-easy/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/stable/wg-easy/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/stable/wg-easy/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/stable/wg-easy/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/stable/wg-easy/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/stable/wg-easy/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/stable/wg-easy/templates/library/base_v2_1_8/volume_types.py b/ix-dev/stable/wg-easy/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/stable/wg-easy/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/stable/wg-easy/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/stable/wg-easy/templates/library/base_v2_1_8/volumes.py b/ix-dev/stable/wg-easy/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/stable/wg-easy/templates/library/base_v2_1_8/volumes.py rename to ix-dev/stable/wg-easy/templates/library/base_v2_1_9/volumes.py diff --git a/ix-dev/test/ix-remote-assist/app.yaml b/ix-dev/test/ix-remote-assist/app.yaml index 5a3ca00d84..2c44a39d6c 100644 --- a/ix-dev/test/ix-remote-assist/app.yaml +++ b/ix-dev/test/ix-remote-assist/app.yaml @@ -23,8 +23,8 @@ icon: https://media.sys.truenas.net/apps/ix-chart/icons/icon.webp keywords: - remote assistance - vpn -lib_version: 2.1.8 -lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44 +lib_version: 2.1.9 +lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125 maintainers: - email: dev@ixsystems.com name: truenas @@ -42,4 +42,4 @@ sources: - https://hub.docker.com/r/tailscale/tailscale title: Remote Assist train: test -version: 1.0.0 +version: 1.0.1 diff --git a/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/container.py b/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/container.py deleted file mode 100644 index 3e787abc5f..0000000000 --- a/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/container.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - from storage import IxStorage - -try: - from .configs import ContainerConfigs - from .depends import Depends - from .deploy import Deploy - from .devices import Devices - from .dns import Dns - from .environment import Environment - from .error import RenderError - from .expose import Expose - from .formatter import escape_dollar, get_image_with_hashed_data - from .healthcheck import Healthcheck - from .labels import Labels - from .ports import Ports - from .restart import RestartPolicy - from .validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from .storage import Storage - from .sysctls import Sysctls -except ImportError: - from configs import ContainerConfigs - from depends import Depends - from deploy import Deploy - from devices import Devices - from dns import Dns - from environment import Environment - from error import RenderError - from expose import Expose - from formatter import escape_dollar, get_image_with_hashed_data - from healthcheck import Healthcheck - from labels import Labels - from ports import Ports - from restart import RestartPolicy - from validations import ( - valid_network_mode_or_raise, - valid_cap_or_raise, - valid_pull_policy_or_raise, - valid_port_bind_mode_or_raise, - ) - from storage import Storage - from sysctls import Sysctls - - -class Container: - def __init__(self, render_instance: "Render", name: str, image: str): - self._render_instance = render_instance - - self._name: str = name - self._image: str = self._resolve_image(image) - self._build_image: str = "" - self._pull_policy: str = "" - self._user: str = "" - self._tty: bool = False - self._stdin_open: bool = False - self._init: bool | None = None - self._read_only: bool | None = None - self._hostname: str = "" - self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly - self._cap_add: set[str] = set() - self._security_opt: set[str] = set(["no-new-privileges"]) - self._privileged: bool = False - self._group_add: set[int | str] = set() - self._network_mode: str = "" - self._entrypoint: list[str] = [] - self._command: list[str] = [] - self._grace_period: int | None = None - self._shm_size: int | None = None - self._storage: Storage = Storage(self._render_instance) - self.sysctls: Sysctls = Sysctls(self._render_instance, self) - self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) - self.deploy: Deploy = Deploy(self._render_instance) - self.networks: set[str] = set() - self.devices: Devices = Devices(self._render_instance) - self.environment: Environment = Environment(self._render_instance, self.deploy.resources) - self.dns: Dns = Dns(self._render_instance) - self.depends: Depends = Depends(self._render_instance) - self.healthcheck: Healthcheck = Healthcheck(self._render_instance) - self.labels: Labels = Labels(self._render_instance) - self.restart: RestartPolicy = RestartPolicy(self._render_instance) - self.ports: Ports = Ports(self._render_instance) - self.expose: Expose = Expose(self._render_instance) - - self._auto_set_network_mode() - self._auto_add_labels() - self._auto_add_groups() - - def _auto_add_groups(self): - self.add_group(568) - - def _auto_set_network_mode(self): - if self._render_instance.values.get("network", {}).get("host_network", False): - self.set_network_mode("host") - - def _auto_add_labels(self): - labels = self._render_instance.values.get("labels", []) - if not labels: - return - - for label in labels: - containers = label.get("containers", []) - if not containers: - raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') - - if self._name in containers: - self.labels.add_label(label["key"], label["value"]) - - def _resolve_image(self, image: str): - images = self._render_instance.values["images"] - if image not in images: - raise RenderError( - f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" - ) - repo = images[image].get("repository", "") - tag = images[image].get("tag", "") - - if not repo: - raise RenderError(f"Repository not found for image [{image}]") - if not tag: - raise RenderError(f"Tag not found for image [{image}]") - - return f"{repo}:{tag}" - - def build_image(self, content: list[str | None]): - dockerfile = f"FROM {self._image}\n" - for line in content: - if not line: - continue - if line.startswith("FROM"): - # TODO: This will also block multi-stage builds - # We can revisit this later if we need it - raise RenderError( - "FROM cannot be used in build image. Define the base image when creating the container." - ) - dockerfile += line + "\n" - - self._build_image = dockerfile - self._image = get_image_with_hashed_data(self._image, dockerfile) - - def set_pull_policy(self, pull_policy: str): - self._pull_policy = valid_pull_policy_or_raise(pull_policy) - - def set_user(self, user: int, group: int): - for i in (user, group): - if not isinstance(i, int) or i < 0: - raise RenderError(f"User/Group [{i}] is not valid") - self._user = f"{user}:{group}" - - def add_group(self, group: int | str): - if isinstance(group, str): - group = str(group).strip() - if group.isdigit(): - raise RenderError(f"Group is a number [{group}] but passed as a string") - - if group in self._group_add: - raise RenderError(f"Group [{group}] already added") - self._group_add.add(group) - - def get_additional_groups(self) -> list[int | str]: - result = [] - if self.deploy.resources.has_gpus() or self.devices.has_gpus(): - result.append(44) # video - result.append(107) # render - return result - - def get_current_groups(self) -> list[str]: - result = [str(g) for g in self._group_add] - result.extend([str(g) for g in self.get_additional_groups()]) - return result - - def set_tty(self, enabled: bool = False): - self._tty = enabled - - def set_stdin(self, enabled: bool = False): - self._stdin_open = enabled - - def set_init(self, enabled: bool = False): - self._init = enabled - - def set_read_only(self, enabled: bool = False): - self._read_only = enabled - - def set_hostname(self, hostname: str): - self._hostname = hostname - - def set_grace_period(self, grace_period: int): - if grace_period < 0: - raise RenderError(f"Grace period [{grace_period}] cannot be negative") - self._grace_period = grace_period - - def set_privileged(self, enabled: bool = False): - self._privileged = enabled - - def clear_caps(self): - self._cap_add.clear() - self._cap_drop.clear() - - def add_caps(self, caps: list[str]): - for c in caps: - if c in self._cap_add: - raise RenderError(f"Capability [{c}] already added") - self._cap_add.add(valid_cap_or_raise(c)) - - def add_security_opt(self, opt: str): - if opt in self._security_opt: - raise RenderError(f"Security Option [{opt}] already added") - self._security_opt.add(opt) - - def remove_security_opt(self, opt: str): - self._security_opt.remove(opt) - - def set_network_mode(self, mode: str): - self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) - - def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): - port_config = port_config or {} - dev_config = dev_config or {} - # Merge port_config and dev_config (dev_config has precedence) - config = port_config | dev_config - - bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) - # Skip port if its neither published nor exposed - if not bind_mode: - return - - # Collect port config - host_port = config.get("port_number", 0) - container_port = config.get("container_port", 0) or host_port - protocol = config.get("protocol", "tcp") - host_ip = config.get("host_ip", "0.0.0.0") - - if bind_mode == "published": - self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) - elif bind_mode == "exposed": - self.expose.add_port(container_port, protocol) - - def set_entrypoint(self, entrypoint: list[str]): - self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] - - def set_command(self, command: list[str]): - self._command = [escape_dollar(str(e)) for e in command] - - def add_storage(self, mount_path: str, config: "IxStorage"): - self._storage.add(mount_path, config) - - def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): - self.add_group(999) - self._storage._add_docker_socket(read_only, mount_path) - - def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): - self._storage._add_udev(read_only, mount_path) - - def add_tun_device(self): - self.devices._add_tun_device() - - def add_snd_device(self): - self.add_group(29) - self.devices._add_snd_device() - - def set_shm_size_mb(self, size: int): - self._shm_size = size - - # Easily remove devices from the container - # Useful in dependencies like postgres and redis - # where there is no need to pass devices to them - def remove_devices(self): - self.deploy.resources.remove_devices() - self.devices.remove_devices() - - @property - def storage(self): - return self._storage - - def render(self) -> dict[str, Any]: - if self._network_mode and self.networks: - raise RenderError("Cannot set both [network_mode] and [networks]") - - result = { - "image": self._image, - "platform": "linux/amd64", - "tty": self._tty, - "stdin_open": self._stdin_open, - "restart": self.restart.render(), - } - - if self._pull_policy: - result["pull_policy"] = self._pull_policy - - if self.healthcheck.has_healthcheck(): - result["healthcheck"] = self.healthcheck.render() - - if self._hostname: - result["hostname"] = self._hostname - - if self._build_image: - result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} - - if self.configs.has_configs(): - result["configs"] = self.configs.render() - - if self._init is not None: - result["init"] = self._init - - if self._read_only is not None: - result["read_only"] = self._read_only - - if self._grace_period is not None: - result["stop_grace_period"] = f"{self._grace_period}s" - - if self._user: - result["user"] = self._user - - for g in self.get_additional_groups(): - self.add_group(g) - - if self._group_add: - result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) - - if self._shm_size is not None: - result["shm_size"] = f"{self._shm_size}M" - - if self._privileged is not None: - result["privileged"] = self._privileged - - if self._cap_drop: - result["cap_drop"] = sorted(self._cap_drop) - - if self._cap_add: - result["cap_add"] = sorted(self._cap_add) - - if self._security_opt: - result["security_opt"] = sorted(self._security_opt) - - if self._network_mode: - result["network_mode"] = self._network_mode - - if self.sysctls.has_sysctls(): - result["sysctls"] = self.sysctls.render() - - if self._network_mode != "host": - if self.ports.has_ports(): - result["ports"] = self.ports.render() - - if self.expose.has_ports(): - result["expose"] = self.expose.render() - - if self._entrypoint: - result["entrypoint"] = self._entrypoint - - if self._command: - result["command"] = self._command - - if self.devices.has_devices(): - result["devices"] = self.devices.render() - - if self.deploy.has_deploy(): - result["deploy"] = self.deploy.render() - - if self.environment.has_variables(): - result["environment"] = self.environment.render() - - if self.labels.has_labels(): - result["labels"] = self.labels.render() - - if self.dns.has_dns_nameservers(): - result["dns"] = self.dns.render_dns_nameservers() - - if self.dns.has_dns_searches(): - result["dns_search"] = self.dns.render_dns_searches() - - if self.dns.has_dns_opts(): - result["dns_opt"] = self.dns.render_dns_opts() - - if self.depends.has_dependencies(): - result["depends_on"] = self.depends.render() - - if self._storage.has_mounts(): - result["volumes"] = self._storage.render() - - return result diff --git a/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/ports.py b/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/ports.py deleted file mode 100644 index 0512108116..0000000000 --- a/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/ports.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from render import Render - -try: - from .error import RenderError - from .validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) -except ImportError: - from error import RenderError - from validations import ( - valid_ip_or_raise, - valid_port_mode_or_raise, - valid_port_or_raise, - valid_port_protocol_or_raise, - ) - - -class Ports: - def __init__(self, render_instance: "Render"): - self._render_instance = render_instance - self._ports: dict[str, dict] = {} - - def add_port(self, host_port: int, container_port: int, config: dict | None = None): - config = config or {} - host_port = valid_port_or_raise(host_port) - container_port = valid_port_or_raise(container_port) - proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) - mode = valid_port_mode_or_raise(config.get("mode", "ingress")) - host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0")) - - key = f"{host_port}_{host_ip}_{proto}" - if key in self._ports.keys(): - raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]") - - if host_ip != "0.0.0.0": - # If the port we are adding is not going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to 0.0.0.0 - search_key = f"{host_port}_0.0.0.0_{proto}" - if search_key in self._ports.keys(): - raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]") - elif host_ip == "0.0.0.0": - # If the port we are adding is going to use 0.0.0.0 - # Make sure that we don't have already added that port/proto to a specific ip - for p in self._ports.values(): - if p["published"] == host_port and p["protocol"] == proto: - raise RenderError( - f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]" - ) - - self._ports[key] = { - "published": host_port, - "target": container_port, - "protocol": proto, - "mode": mode, - "host_ip": host_ip, - } - - def has_ports(self): - return len(self._ports) > 0 - - def render(self): - return [config for _, config in sorted(self._ports.items())] diff --git a/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/tests/test_container.py b/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/tests/test_container.py deleted file mode 100644 index 1e3c5c81c3..0000000000 --- a/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/tests/test_container.py +++ /dev/null @@ -1,361 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_empty_container_name(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container(" ", "test_image") - - -def test_resolve_image(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["image"] == "nginx:latest" - - -def test_missing_repo(mock_values): - mock_values["images"]["test_image"]["repository"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_missing_tag(mock_values): - mock_values["images"]["test_image"]["tag"] = "" - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "test_image") - - -def test_non_existing_image(mock_values): - render = Render(mock_values) - with pytest.raises(Exception): - render.add_container("test_container", "non_existing_image") - - -def test_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_pull_policy("always") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["pull_policy"] == "always" - - -def test_invalid_pull_policy(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - with pytest.raises(Exception): - c1.set_pull_policy("invalid_policy") - - -def test_clear_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.add_caps(["NET_ADMIN"]) - c1.clear_caps() - c1.healthcheck.disable() - output = render.render() - assert "cap_drop" not in output["services"]["test_container"] - assert "cap_add" not in output["services"]["test_container"] - - -def test_privileged(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_privileged(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["privileged"] is True - - -def test_tty(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_tty(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["tty"] is True - - -def test_init(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_init(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["init"] is True - - -def test_read_only(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_read_only(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["read_only"] is True - - -def test_stdin(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_stdin(True) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stdin_open"] is True - - -def test_hostname(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_hostname("test_hostname") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["hostname"] == "test_hostname" - - -def test_grace_period(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_grace_period(10) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["stop_grace_period"] == "10s" - - -def test_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_user(1000, 1000) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["user"] == "1000:1000" - - -def test_invalid_user(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_user(-100, 1000) - - -def test_add_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - c1.add_group("video") - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] - - -def test_add_duplicate_group(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_group(1000) - with pytest.raises(Exception): - c1.add_group(1000) - - -def test_add_group_as_string(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_group("1000") - - -def test_add_docker_socket(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_docker_socket() - output = render.render() - assert output["services"]["test_container"]["group_add"] == [568, 999] - assert output["services"]["test_container"]["volumes"] == [ - { - "type": "bind", - "source": "/var/run/docker.sock", - "target": "/var/run/docker.sock", - "read_only": True, - "bind": { - "propagation": "rprivate", - "create_host_path": False, - }, - } - ] - - -def test_snd_device(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_snd_device() - output = render.render() - assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] - assert output["services"]["test_container"]["group_add"] == [29, 568] - - -def test_shm_size(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_shm_size_mb(10) - output = render.render() - assert output["services"]["test_container"]["shm_size"] == "10M" - - -def test_valid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_caps(["ALL", "NET_ADMIN"]) - output = render.render() - assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] - assert output["services"]["test_container"]["cap_drop"] == ["ALL"] - - -def test_add_duplicate_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) - - -def test_invalid_caps(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_caps(["invalid_cap"]) - - -def test_remove_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.remove_security_opt("no-new-privileges") - output = render.render() - assert "security_opt" not in output["services"]["test_container"] - - -def test_add_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_security_opt("seccomp=unconfined") - output = render.render() - assert output["services"]["test_container"]["security_opt"] == [ - "no-new-privileges", - "seccomp=unconfined", - ] - - -def test_add_duplicate_security_opt(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.add_security_opt("no-new-privileges") - - -def test_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("host") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_auto_network_mode_with_host_network(mock_values): - mock_values["network"] = {"host_network": True} - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "host" - - -def test_network_mode_with_container(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.set_network_mode("service:test_container") - output = render.render() - assert output["services"]["test_container"]["network_mode"] == "service:test_container" - - -def test_network_mode_with_container_missing(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("service:missing_container") - - -def test_invalid_network_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.set_network_mode("invalid_mode") - - -def test_entrypoint(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] - - -def test_command(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.set_command(["echo", "hello $MY_ENV"]) - c1.healthcheck.disable() - output = render.render() - assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) - c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) - c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) - c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) - c1.add_port( - {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, - {"container_port": 9092, "protocol": "udp"}, - ) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - assert output["services"]["test_container"]["expose"] == ["8080/tcp"] diff --git a/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/tests/test_ports.py b/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/tests/test_ports.py deleted file mode 100644 index a4c923ca1d..0000000000 --- a/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/tests/test_ports.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - - -from render import Render - - -@pytest.fixture -def mock_values(): - return { - "images": { - "test_image": { - "repository": "nginx", - "tag": "latest", - } - }, - } - - -def test_add_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8082, 8080, {"protocol": "udp"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"}, - {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"}, - ] - - -def test_add_duplicate_ports(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080) - c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080) - - -def test_add_duplicate_ports_with_different_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"}) - output = render.render() - assert output["services"]["test_container"]["ports"] == [ - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, - {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, - ] - - -def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - - -def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"}) - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"}) - - -def test_add_ports_with_invalid_protocol(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"}) - - -def test_add_ports_with_invalid_mode(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"}) - - -def test_add_ports_with_invalid_ip(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"}) - - -def test_add_ports_with_invalid_host_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(-1, 8080) - - -def test_add_ports_with_invalid_container_port(mock_values): - render = Render(mock_values) - c1 = render.add_container("test_container", "test_image") - c1.healthcheck.disable() - with pytest.raises(Exception): - c1.ports.add_port(8081, -1) diff --git a/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/__init__.py b/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/__init__.py similarity index 100% rename from ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/__init__.py rename to ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/__init__.py diff --git a/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/configs.py b/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/configs.py similarity index 100% rename from ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/configs.py rename to ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/configs.py diff --git a/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/container.py b/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/container.py new file mode 100644 index 0000000000..d0abd6edc7 --- /dev/null +++ b/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/container.py @@ -0,0 +1,391 @@ +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + from storage import IxStorage + +try: + from .configs import ContainerConfigs + from .depends import Depends + from .deploy import Deploy + from .devices import Devices + from .dns import Dns + from .environment import Environment + from .error import RenderError + from .expose import Expose + from .formatter import escape_dollar, get_image_with_hashed_data + from .healthcheck import Healthcheck + from .labels import Labels + from .ports import Ports + from .restart import RestartPolicy + from .validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from .storage import Storage + from .sysctls import Sysctls +except ImportError: + from configs import ContainerConfigs + from depends import Depends + from deploy import Deploy + from devices import Devices + from dns import Dns + from environment import Environment + from error import RenderError + from expose import Expose + from formatter import escape_dollar, get_image_with_hashed_data + from healthcheck import Healthcheck + from labels import Labels + from ports import Ports + from restart import RestartPolicy + from validations import ( + valid_network_mode_or_raise, + valid_cap_or_raise, + valid_pull_policy_or_raise, + valid_port_bind_mode_or_raise, + ) + from storage import Storage + from sysctls import Sysctls + + +class Container: + def __init__(self, render_instance: "Render", name: str, image: str): + self._render_instance = render_instance + + self._name: str = name + self._image: str = self._resolve_image(image) + self._build_image: str = "" + self._pull_policy: str = "" + self._user: str = "" + self._tty: bool = False + self._stdin_open: bool = False + self._init: bool | None = None + self._read_only: bool | None = None + self._hostname: str = "" + self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly + self._cap_add: set[str] = set() + self._security_opt: set[str] = set(["no-new-privileges"]) + self._privileged: bool = False + self._group_add: set[int | str] = set() + self._network_mode: str = "" + self._entrypoint: list[str] = [] + self._command: list[str] = [] + self._grace_period: int | None = None + self._shm_size: int | None = None + self._storage: Storage = Storage(self._render_instance) + self.sysctls: Sysctls = Sysctls(self._render_instance, self) + self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs) + self.deploy: Deploy = Deploy(self._render_instance) + self.networks: set[str] = set() + self.devices: Devices = Devices(self._render_instance) + self.environment: Environment = Environment(self._render_instance, self.deploy.resources) + self.dns: Dns = Dns(self._render_instance) + self.depends: Depends = Depends(self._render_instance) + self.healthcheck: Healthcheck = Healthcheck(self._render_instance) + self.labels: Labels = Labels(self._render_instance) + self.restart: RestartPolicy = RestartPolicy(self._render_instance) + self.ports: Ports = Ports(self._render_instance) + self.expose: Expose = Expose(self._render_instance) + + self._auto_set_network_mode() + self._auto_add_labels() + self._auto_add_groups() + + def _auto_add_groups(self): + self.add_group(568) + + def _auto_set_network_mode(self): + if self._render_instance.values.get("network", {}).get("host_network", False): + self.set_network_mode("host") + + def _auto_add_labels(self): + labels = self._render_instance.values.get("labels", []) + if not labels: + return + + for label in labels: + containers = label.get("containers", []) + if not containers: + raise RenderError(f'Label [{label.get("key", "")}] must have at least one container') + + if self._name in containers: + self.labels.add_label(label["key"], label["value"]) + + def _resolve_image(self, image: str): + images = self._render_instance.values["images"] + if image not in images: + raise RenderError( + f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]" + ) + repo = images[image].get("repository", "") + tag = images[image].get("tag", "") + + if not repo: + raise RenderError(f"Repository not found for image [{image}]") + if not tag: + raise RenderError(f"Tag not found for image [{image}]") + + return f"{repo}:{tag}" + + def build_image(self, content: list[str | None]): + dockerfile = f"FROM {self._image}\n" + for line in content: + if not line: + continue + if line.startswith("FROM"): + # TODO: This will also block multi-stage builds + # We can revisit this later if we need it + raise RenderError( + "FROM cannot be used in build image. Define the base image when creating the container." + ) + dockerfile += line + "\n" + + self._build_image = dockerfile + self._image = get_image_with_hashed_data(self._image, dockerfile) + + def set_pull_policy(self, pull_policy: str): + self._pull_policy = valid_pull_policy_or_raise(pull_policy) + + def set_user(self, user: int, group: int): + for i in (user, group): + if not isinstance(i, int) or i < 0: + raise RenderError(f"User/Group [{i}] is not valid") + self._user = f"{user}:{group}" + + def add_group(self, group: int | str): + if isinstance(group, str): + group = str(group).strip() + if group.isdigit(): + raise RenderError(f"Group is a number [{group}] but passed as a string") + + if group in self._group_add: + raise RenderError(f"Group [{group}] already added") + self._group_add.add(group) + + def get_additional_groups(self) -> list[int | str]: + result = [] + if self.deploy.resources.has_gpus() or self.devices.has_gpus(): + result.append(44) # video + result.append(107) # render + return result + + def get_current_groups(self) -> list[str]: + result = [str(g) for g in self._group_add] + result.extend([str(g) for g in self.get_additional_groups()]) + return result + + def set_tty(self, enabled: bool = False): + self._tty = enabled + + def set_stdin(self, enabled: bool = False): + self._stdin_open = enabled + + def set_init(self, enabled: bool = False): + self._init = enabled + + def set_read_only(self, enabled: bool = False): + self._read_only = enabled + + def set_hostname(self, hostname: str): + self._hostname = hostname + + def set_grace_period(self, grace_period: int): + if grace_period < 0: + raise RenderError(f"Grace period [{grace_period}] cannot be negative") + self._grace_period = grace_period + + def set_privileged(self, enabled: bool = False): + self._privileged = enabled + + def clear_caps(self): + self._cap_add.clear() + self._cap_drop.clear() + + def add_caps(self, caps: list[str]): + for c in caps: + if c in self._cap_add: + raise RenderError(f"Capability [{c}] already added") + self._cap_add.add(valid_cap_or_raise(c)) + + def add_security_opt(self, opt: str): + if opt in self._security_opt: + raise RenderError(f"Security Option [{opt}] already added") + self._security_opt.add(opt) + + def remove_security_opt(self, opt: str): + self._security_opt.remove(opt) + + def set_network_mode(self, mode: str): + self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names()) + + def add_port(self, port_config: dict | None = None, dev_config: dict | None = None): + port_config = port_config or {} + dev_config = dev_config or {} + # Merge port_config and dev_config (dev_config has precedence) + config = port_config | dev_config + + bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", "")) + # Skip port if its neither published nor exposed + if not bind_mode: + return + + # Collect port config + host_port = config.get("port_number", 0) + container_port = config.get("container_port", 0) or host_port + protocol = config.get("protocol", "tcp") + host_ips = config.get("host_ips", ["0.0.0.0", "::"]) + if not isinstance(host_ips, list): + raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]") + + if bind_mode == "published": + for host_ip in host_ips: + self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip}) + elif bind_mode == "exposed": + self.expose.add_port(container_port, protocol) + + def set_entrypoint(self, entrypoint: list[str]): + self._entrypoint = [escape_dollar(str(e)) for e in entrypoint] + + def set_command(self, command: list[str]): + self._command = [escape_dollar(str(e)) for e in command] + + def add_storage(self, mount_path: str, config: "IxStorage"): + self._storage.add(mount_path, config) + + def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"): + self.add_group(999) + self._storage._add_docker_socket(read_only, mount_path) + + def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"): + self._storage._add_udev(read_only, mount_path) + + def add_tun_device(self): + self.devices._add_tun_device() + + def add_snd_device(self): + self.add_group(29) + self.devices._add_snd_device() + + def set_shm_size_mb(self, size: int): + self._shm_size = size + + # Easily remove devices from the container + # Useful in dependencies like postgres and redis + # where there is no need to pass devices to them + def remove_devices(self): + self.deploy.resources.remove_devices() + self.devices.remove_devices() + + @property + def storage(self): + return self._storage + + def render(self) -> dict[str, Any]: + if self._network_mode and self.networks: + raise RenderError("Cannot set both [network_mode] and [networks]") + + result = { + "image": self._image, + "platform": "linux/amd64", + "tty": self._tty, + "stdin_open": self._stdin_open, + "restart": self.restart.render(), + } + + if self._pull_policy: + result["pull_policy"] = self._pull_policy + + if self.healthcheck.has_healthcheck(): + result["healthcheck"] = self.healthcheck.render() + + if self._hostname: + result["hostname"] = self._hostname + + if self._build_image: + result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image} + + if self.configs.has_configs(): + result["configs"] = self.configs.render() + + if self._init is not None: + result["init"] = self._init + + if self._read_only is not None: + result["read_only"] = self._read_only + + if self._grace_period is not None: + result["stop_grace_period"] = f"{self._grace_period}s" + + if self._user: + result["user"] = self._user + + for g in self.get_additional_groups(): + self.add_group(g) + + if self._group_add: + result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g)) + + if self._shm_size is not None: + result["shm_size"] = f"{self._shm_size}M" + + if self._privileged is not None: + result["privileged"] = self._privileged + + if self._cap_drop: + result["cap_drop"] = sorted(self._cap_drop) + + if self._cap_add: + result["cap_add"] = sorted(self._cap_add) + + if self._security_opt: + result["security_opt"] = sorted(self._security_opt) + + if self._network_mode: + result["network_mode"] = self._network_mode + + if self.sysctls.has_sysctls(): + result["sysctls"] = self.sysctls.render() + + if self._network_mode != "host": + if self.ports.has_ports(): + result["ports"] = self.ports.render() + + if self.expose.has_ports(): + result["expose"] = self.expose.render() + + if self._entrypoint: + result["entrypoint"] = self._entrypoint + + if self._command: + result["command"] = self._command + + if self.devices.has_devices(): + result["devices"] = self.devices.render() + + if self.deploy.has_deploy(): + result["deploy"] = self.deploy.render() + + if self.environment.has_variables(): + result["environment"] = self.environment.render() + + if self.labels.has_labels(): + result["labels"] = self.labels.render() + + if self.dns.has_dns_nameservers(): + result["dns"] = self.dns.render_dns_nameservers() + + if self.dns.has_dns_searches(): + result["dns_search"] = self.dns.render_dns_searches() + + if self.dns.has_dns_opts(): + result["dns_opt"] = self.dns.render_dns_opts() + + if self.depends.has_dependencies(): + result["depends_on"] = self.depends.render() + + if self._storage.has_mounts(): + result["volumes"] = self._storage.render() + + return result diff --git a/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/depends.py b/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/depends.py similarity index 100% rename from ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/depends.py rename to ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/depends.py diff --git a/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/deploy.py b/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/deploy.py similarity index 100% rename from ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/deploy.py rename to ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/deploy.py diff --git a/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/deps.py b/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/deps.py similarity index 100% rename from ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/deps.py rename to ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/deps.py diff --git a/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/deps_mariadb.py b/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/deps_mariadb.py similarity index 100% rename from ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/deps_mariadb.py rename to ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/deps_mariadb.py diff --git a/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/deps_perms.py b/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/deps_perms.py similarity index 100% rename from ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/deps_perms.py rename to ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/deps_perms.py diff --git a/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/deps_postgres.py b/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/deps_postgres.py similarity index 100% rename from ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/deps_postgres.py rename to ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/deps_postgres.py diff --git a/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/deps_redis.py b/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/deps_redis.py similarity index 100% rename from ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/deps_redis.py rename to ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/deps_redis.py diff --git a/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/device.py b/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/device.py similarity index 100% rename from ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/device.py rename to ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/device.py diff --git a/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/devices.py b/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/devices.py similarity index 100% rename from ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/devices.py rename to ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/devices.py diff --git a/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/dns.py b/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/dns.py similarity index 100% rename from ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/dns.py rename to ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/dns.py diff --git a/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/environment.py b/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/environment.py similarity index 100% rename from ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/environment.py rename to ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/environment.py diff --git a/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/error.py b/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/error.py similarity index 100% rename from ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/error.py rename to ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/error.py diff --git a/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/expose.py b/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/expose.py similarity index 100% rename from ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/expose.py rename to ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/expose.py diff --git a/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/formatter.py b/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/formatter.py similarity index 100% rename from ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/formatter.py rename to ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/formatter.py diff --git a/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/functions.py b/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/functions.py similarity index 100% rename from ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/functions.py rename to ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/functions.py diff --git a/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/healthcheck.py b/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/healthcheck.py similarity index 100% rename from ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/healthcheck.py rename to ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/healthcheck.py diff --git a/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/labels.py b/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/labels.py similarity index 100% rename from ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/labels.py rename to ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/labels.py diff --git a/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/notes.py b/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/notes.py similarity index 100% rename from ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/notes.py rename to ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/notes.py diff --git a/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/portal.py b/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/portal.py similarity index 100% rename from ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/portal.py rename to ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/portal.py diff --git a/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/portals.py b/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/portals.py similarity index 100% rename from ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/portals.py rename to ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/portals.py diff --git a/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/ports.py b/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/ports.py new file mode 100644 index 0000000000..e571232ac9 --- /dev/null +++ b/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/ports.py @@ -0,0 +1,153 @@ +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from render import Render + +try: + from .error import RenderError + from .validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) +except ImportError: + from error import RenderError + from validations import ( + valid_ip_or_raise, + valid_port_mode_or_raise, + valid_port_or_raise, + valid_port_protocol_or_raise, + ) + + +class Ports: + def __init__(self, render_instance: "Render"): + self._render_instance = render_instance + self._ports: dict[str, dict] = {} + + def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str: + return f"{host_port}_{host_ip}_{proto}_{ip_family}" + + def _is_wildcard_ip(self, ip: str) -> bool: + return ip in ["0.0.0.0", "::"] + + def _get_opposite_wildcard(self, ip: str) -> str: + return "0.0.0.0" if ip == "::" else "::" + + def _get_sort_key(self, p: dict) -> str: + return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}" + + def _is_ports_same(self, port1: dict, port2: dict) -> bool: + return ( + port1["published"] == port2["published"] + and port1["target"] == port2["target"] + and port1["protocol"] == port2["protocol"] + and port1.get("host_ip", "_") == port2.get("host_ip", "_") + ) + + def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool: + comparison_port = port_config.copy() + comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"]) + for p in wildcard_ports.values(): + if self._is_ports_same(comparison_port, p): + return True + return False + + def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None: + host_port = port_config["published"] + host_ip = port_config["host_ip"] + proto = port_config["protocol"] + + key = self._gen_port_key(host_port, host_ip, proto, ip_family) + + if key in self._ports.keys(): + raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]") + + wildcard_ip = "0.0.0.0" if ip_family == 4 else "::" + if host_ip != wildcard_ip: + # Check if there is a port with same details but with wildcard IP of the same family + wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family) + if wildcard_key in self._ports.keys(): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{wildcard_ip}]" + ) + else: + # We are adding a port with wildcard IP + # Check if there is a port with same details but with specific IP of the same family + for p in self._ports.values(): + # Skip if the port is not for the same family + if ip_family != ipaddress.ip_address(p["host_ip"]).version: + continue + + # Make a copy of the port config + search_port = p.copy() + # Replace the host IP with wildcard IP + search_port["host_ip"] = wildcard_ip + # If the ports match, means that a port for specific IP is already added + # and we are trying to add it again with wildcard IP. Raise an error + if self._is_ports_same(search_port, port_config): + raise RenderError( + f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], " + f"already bound to [{p['host_ip']}]" + ) + + def add_port(self, host_port: int, container_port: int, config: dict | None = None): + config = config or {} + host_port = valid_port_or_raise(host_port) + container_port = valid_port_or_raise(container_port) + proto = valid_port_protocol_or_raise(config.get("protocol", "tcp")) + mode = valid_port_mode_or_raise(config.get("mode", "ingress")) + + # TODO: Once all apps stop using this function directly, (ie using the container.add_port function) + # Remove this, and let container.add_port call this for each host_ip + host_ip = config.get("host_ip", None) + if host_ip is None: + self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"}) + self.add_port(host_port, container_port, config | {"host_ip": "::"}) + return + + host_ip = valid_ip_or_raise(config.get("host_ip", None)) + ip = ipaddress.ip_address(host_ip) + + port_config = { + "published": host_port, + "target": container_port, + "protocol": proto, + "mode": mode, + "host_ip": host_ip, + } + self._check_port_conflicts(port_config, ip.version) + + key = self._gen_port_key(host_port, host_ip, proto, ip.version) + self._ports[key] = port_config + + def has_ports(self): + return len(self._ports) > 0 + + def render(self): + specific_ports = [] + wildcard_ports = {} + + for port_config in self._ports.values(): + if self._is_wildcard_ip(port_config["host_ip"]): + wildcard_ports[id(port_config)] = port_config.copy() + else: + specific_ports.append(port_config.copy()) + + processed_ports = specific_ports.copy() + for wild_port in wildcard_ports.values(): + processed_port = wild_port.copy() + + # Check if there's a matching wildcard port for the opposite IP family + has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports) + + if has_opposite_family: + processed_port.pop("host_ip") + + if processed_port not in processed_ports: + processed_ports.append(processed_port) + + return sorted(processed_ports, key=self._get_sort_key) diff --git a/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/render.py b/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/render.py similarity index 100% rename from ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/render.py rename to ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/render.py diff --git a/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/resources.py b/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/resources.py similarity index 100% rename from ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/resources.py rename to ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/resources.py diff --git a/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/restart.py b/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/restart.py similarity index 100% rename from ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/restart.py rename to ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/restart.py diff --git a/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/storage.py b/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/storage.py similarity index 100% rename from ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/storage.py rename to ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/storage.py diff --git a/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/sysctls.py b/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/sysctls.py similarity index 100% rename from ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/sysctls.py rename to ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/sysctls.py diff --git a/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/tests/__init__.py b/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/tests/__init__.py similarity index 100% rename from ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/tests/__init__.py rename to ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/tests/__init__.py diff --git a/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/tests/test_build_image.py b/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/tests/test_build_image.py similarity index 100% rename from ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/tests/test_build_image.py rename to ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/tests/test_build_image.py diff --git a/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/tests/test_configs.py b/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/tests/test_configs.py similarity index 100% rename from ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/tests/test_configs.py rename to ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/tests/test_configs.py diff --git a/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/tests/test_container.py b/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/tests/test_container.py new file mode 100644 index 0000000000..bb3d98dffc --- /dev/null +++ b/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/tests/test_container.py @@ -0,0 +1,369 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +def test_empty_container_name(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container(" ", "test_image") + + +def test_resolve_image(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["image"] == "nginx:latest" + + +def test_missing_repo(mock_values): + mock_values["images"]["test_image"]["repository"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_missing_tag(mock_values): + mock_values["images"]["test_image"]["tag"] = "" + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "test_image") + + +def test_non_existing_image(mock_values): + render = Render(mock_values) + with pytest.raises(Exception): + render.add_container("test_container", "non_existing_image") + + +def test_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_pull_policy("always") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["pull_policy"] == "always" + + +def test_invalid_pull_policy(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + with pytest.raises(Exception): + c1.set_pull_policy("invalid_policy") + + +def test_clear_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.add_caps(["NET_ADMIN"]) + c1.clear_caps() + c1.healthcheck.disable() + output = render.render() + assert "cap_drop" not in output["services"]["test_container"] + assert "cap_add" not in output["services"]["test_container"] + + +def test_privileged(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_privileged(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["privileged"] is True + + +def test_tty(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_tty(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["tty"] is True + + +def test_init(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_init(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["init"] is True + + +def test_read_only(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_read_only(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["read_only"] is True + + +def test_stdin(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_stdin(True) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stdin_open"] is True + + +def test_hostname(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_hostname("test_hostname") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["hostname"] == "test_hostname" + + +def test_grace_period(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_grace_period(10) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["stop_grace_period"] == "10s" + + +def test_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_user(1000, 1000) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["user"] == "1000:1000" + + +def test_invalid_user(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_user(-100, 1000) + + +def test_add_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + c1.add_group("video") + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 1000, "video"] + + +def test_add_duplicate_group(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_group(1000) + with pytest.raises(Exception): + c1.add_group(1000) + + +def test_add_group_as_string(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_group("1000") + + +def test_add_docker_socket(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_docker_socket() + output = render.render() + assert output["services"]["test_container"]["group_add"] == [568, 999] + assert output["services"]["test_container"]["volumes"] == [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + "bind": { + "propagation": "rprivate", + "create_host_path": False, + }, + } + ] + + +def test_snd_device(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_snd_device() + output = render.render() + assert output["services"]["test_container"]["devices"] == ["/dev/snd:/dev/snd"] + assert output["services"]["test_container"]["group_add"] == [29, 568] + + +def test_shm_size(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_shm_size_mb(10) + output = render.render() + assert output["services"]["test_container"]["shm_size"] == "10M" + + +def test_valid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_caps(["ALL", "NET_ADMIN"]) + output = render.render() + assert output["services"]["test_container"]["cap_add"] == ["ALL", "NET_ADMIN"] + assert output["services"]["test_container"]["cap_drop"] == ["ALL"] + + +def test_add_duplicate_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["ALL", "NET_ADMIN", "NET_ADMIN"]) + + +def test_invalid_caps(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_caps(["invalid_cap"]) + + +def test_remove_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.remove_security_opt("no-new-privileges") + output = render.render() + assert "security_opt" not in output["services"]["test_container"] + + +def test_add_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_security_opt("seccomp=unconfined") + output = render.render() + assert output["services"]["test_container"]["security_opt"] == [ + "no-new-privileges", + "seccomp=unconfined", + ] + + +def test_add_duplicate_security_opt(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_security_opt("no-new-privileges") + + +def test_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("host") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_auto_network_mode_with_host_network(mock_values): + mock_values["network"] = {"host_network": True} + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "host" + + +def test_network_mode_with_container(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.set_network_mode("service:test_container") + output = render.render() + assert output["services"]["test_container"]["network_mode"] == "service:test_container" + + +def test_network_mode_with_container_missing(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("service:missing_container") + + +def test_invalid_network_mode(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.set_network_mode("invalid_mode") + + +def test_entrypoint(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_entrypoint(["/bin/bash", "-c", "echo hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["entrypoint"] == ["/bin/bash", "-c", "echo hello $$MY_ENV"] + + +def test_command(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.set_command(["echo", "hello $MY_ENV"]) + c1.healthcheck.disable() + output = render.render() + assert output["services"]["test_container"]["command"] == ["echo", "hello $$MY_ENV"] + + +def test_add_ports(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published"}) + c1.add_port({"port_number": 8082, "container_port": 8080, "bind_mode": "published", "protocol": "udp"}) + c1.add_port({"port_number": 8083, "container_port": 8080, "bind_mode": "exposed"}) + c1.add_port({"port_number": 8084, "container_port": 8080, "bind_mode": ""}) + c1.add_port( + {"port_number": 9091, "container_port": 9091, "bind_mode": "published"}, + {"container_port": 9092, "protocol": "udp"}, + ) + output = render.render() + assert output["services"]["test_container"]["ports"] == [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + {"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"}, + ] + assert output["services"]["test_container"]["expose"] == ["8080/tcp"] + + +def test_add_ports_with_invalid_host_ips(mock_values): + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + with pytest.raises(Exception): + c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"}) diff --git a/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/tests/test_depends.py b/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/tests/test_depends.py similarity index 100% rename from ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/tests/test_depends.py rename to ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/tests/test_depends.py diff --git a/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/tests/test_deps.py b/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/tests/test_deps.py similarity index 100% rename from ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/tests/test_deps.py rename to ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/tests/test_deps.py diff --git a/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/tests/test_device.py b/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/tests/test_device.py similarity index 100% rename from ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/tests/test_device.py rename to ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/tests/test_device.py diff --git a/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/tests/test_dns.py b/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/tests/test_dns.py similarity index 100% rename from ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/tests/test_dns.py rename to ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/tests/test_dns.py diff --git a/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/tests/test_environment.py b/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/tests/test_environment.py similarity index 100% rename from ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/tests/test_environment.py rename to ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/tests/test_environment.py diff --git a/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/tests/test_expose.py b/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/tests/test_expose.py similarity index 100% rename from ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/tests/test_expose.py rename to ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/tests/test_expose.py diff --git a/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/tests/test_formatter.py b/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/tests/test_formatter.py similarity index 100% rename from ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/tests/test_formatter.py rename to ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/tests/test_formatter.py diff --git a/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/tests/test_functions.py b/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/tests/test_functions.py similarity index 100% rename from ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/tests/test_functions.py rename to ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/tests/test_functions.py diff --git a/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/tests/test_healthcheck.py b/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/tests/test_healthcheck.py similarity index 100% rename from ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/tests/test_healthcheck.py rename to ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/tests/test_healthcheck.py diff --git a/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/tests/test_labels.py b/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/tests/test_labels.py similarity index 100% rename from ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/tests/test_labels.py rename to ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/tests/test_labels.py diff --git a/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/tests/test_notes.py b/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/tests/test_notes.py similarity index 100% rename from ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/tests/test_notes.py rename to ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/tests/test_notes.py diff --git a/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/tests/test_portal.py b/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/tests/test_portal.py similarity index 100% rename from ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/tests/test_portal.py rename to ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/tests/test_portal.py diff --git a/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/tests/test_ports.py b/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/tests/test_ports.py new file mode 100644 index 0000000000..7b37a03600 --- /dev/null +++ b/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/tests/test_ports.py @@ -0,0 +1,209 @@ +import pytest + + +from render import Render + + +@pytest.fixture +def mock_values(): + return { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + +tests = [ + { + "name": "add_ports_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "add_duplicate_ports_should_fail", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_different_protocol_should_work", + "inputs": [ + {"values": (8081, 8080), "expect_error": False}, + {"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"}, + ], + }, + { + "name": "adding_same_port_for_both_wildcard_families_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + { + "published": 8081, + "target": 8080, + "protocol": "tcp", + "mode": "ingress", + "host_ip": "fd00:1234:5678:abcd::10", + }, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_port_with_different_v4_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + ], + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + }, + { + "name": "adding_port_with_invalid_protocol_should_fail", + "inputs": [ + {"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_mode_should_fail", + "inputs": [ + {"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_ip_should_fail", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_host_port_should_fail", + "inputs": [ + {"values": (-1, 8080), "expect_error": True}, + ], + }, + { + "name": "adding_port_with_invalid_container_port_should_fail", + "inputs": [ + {"values": (8081, -1), "expect_error": True}, + ], + }, + { + "name": "adding_duplicate_ports_with_different_host_ip_should_work", + "inputs": [ + {"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False}, + {"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False}, + ], + # fmt: off + "expected": [ + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"}, + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa + {"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"}, + {"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"}, + ], + # fmt: on + }, +] + + +@pytest.mark.parametrize("test", tests) +def test_ports(test): + mock_values = { + "images": { + "test_image": { + "repository": "nginx", + "tag": "latest", + } + }, + } + + render = Render(mock_values) + c1 = render.add_container("test_container", "test_image") + c1.healthcheck.disable() + + errored = False + for input in test["inputs"]: + if input["expect_error"]: + with pytest.raises(Exception): + c1.ports.add_port(*input["values"]) + errored = True + else: + c1.ports.add_port(*input["values"]) + + errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False + if errored: + return + + output = render.render() + assert output["services"]["test_container"]["ports"] == test["expected"] diff --git a/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/tests/test_render.py b/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/tests/test_render.py similarity index 100% rename from ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/tests/test_render.py rename to ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/tests/test_render.py diff --git a/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/tests/test_resources.py b/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/tests/test_resources.py similarity index 100% rename from ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/tests/test_resources.py rename to ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/tests/test_resources.py diff --git a/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/tests/test_restart.py b/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/tests/test_restart.py similarity index 100% rename from ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/tests/test_restart.py rename to ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/tests/test_restart.py diff --git a/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/tests/test_sysctls.py b/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/tests/test_sysctls.py similarity index 100% rename from ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/tests/test_sysctls.py rename to ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/tests/test_sysctls.py diff --git a/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/tests/test_validations.py b/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/tests/test_validations.py similarity index 100% rename from ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/tests/test_validations.py rename to ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/tests/test_validations.py diff --git a/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/tests/test_volumes.py b/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/tests/test_volumes.py similarity index 100% rename from ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/tests/test_volumes.py rename to ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/tests/test_volumes.py diff --git a/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/validations.py b/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/validations.py similarity index 100% rename from ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/validations.py rename to ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/validations.py diff --git a/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/volume_mount.py b/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/volume_mount.py similarity index 100% rename from ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/volume_mount.py rename to ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/volume_mount.py diff --git a/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/volume_mount_types.py b/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/volume_mount_types.py similarity index 100% rename from ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/volume_mount_types.py rename to ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/volume_mount_types.py diff --git a/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/volume_sources.py b/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/volume_sources.py similarity index 100% rename from ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/volume_sources.py rename to ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/volume_sources.py diff --git a/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/volume_types.py b/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/volume_types.py similarity index 100% rename from ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/volume_types.py rename to ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/volume_types.py diff --git a/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/volumes.py b/ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/volumes.py similarity index 100% rename from ix-dev/test/ix-remote-assist/templates/library/base_v2_1_8/volumes.py rename to ix-dev/test/ix-remote-assist/templates/library/base_v2_1_9/volumes.py